mirror of
https://github.com/StepanovPlaton/SSAUGenerator.git
synced 2026-10-31 10:18:29 +04:00
add pptx
This commit is contained in:
105
pptx/src/build.js
Normal file
105
pptx/src/build.js
Normal file
@@ -0,0 +1,105 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { loadConfig, validateConfig } = require("./config");
|
||||
const { parseSlide } = require("./parse");
|
||||
const { renderDeck } = require("./render");
|
||||
const { resolveImages, isPlantUmlSource } = require("./plantuml");
|
||||
|
||||
function resolveSlideFile(slide, config) {
|
||||
if (path.isAbsolute(slide.file)) return slide.file;
|
||||
return path.resolve(config.content.dir, slide.file);
|
||||
}
|
||||
|
||||
function resolveAssetFile(config, file) {
|
||||
if (path.isAbsolute(file)) return file;
|
||||
return path.resolve(config.content.dir, file);
|
||||
}
|
||||
|
||||
function collectIssues(config) {
|
||||
const issues = [...validateConfig(config)];
|
||||
|
||||
for (const [name, file] of Object.entries(config.theme.backgrounds)) {
|
||||
if (file && !fs.existsSync(file)) {
|
||||
issues.push(`Не найден фон "${name}": ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
(config.slides || []).forEach((slide, i) => {
|
||||
if (!slide || !slide.file) return;
|
||||
const file = resolveSlideFile(slide, config);
|
||||
if (!fs.existsSync(file)) {
|
||||
issues.push(`Слайд #${i + 1}: не найден файл ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function checkImages(config, deck) {
|
||||
const issues = [];
|
||||
for (const entry of deck) {
|
||||
for (const block of entry.parsed.blocks) {
|
||||
if (block.kind !== "image" || !block.file) continue;
|
||||
const resolved = resolveAssetFile(config, block.file);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
const kind = isPlantUmlSource(block.file) ? "исходник PlantUML" : "изображение";
|
||||
issues.push(`Слайд #${entry.index}: не найден ${kind}: ${block.file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function buildDeck(config) {
|
||||
return config.slides.map((slide, i) => {
|
||||
const file = resolveSlideFile(slide, config);
|
||||
const markdown = fs.readFileSync(file, "utf8");
|
||||
return {
|
||||
index: i + 1,
|
||||
file,
|
||||
type: slide.type || "content",
|
||||
parsed: parseSlide(markdown),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function build(options = {}) {
|
||||
const configPath = options.config || "config.yaml";
|
||||
const { config, configDir } = loadConfig(configPath);
|
||||
const issues = collectIssues(config);
|
||||
|
||||
let deck = null;
|
||||
if (issues.length === 0) {
|
||||
deck = buildDeck(config);
|
||||
issues.push(...checkImages(config, deck));
|
||||
}
|
||||
|
||||
if (options.check || options.dryRun) {
|
||||
return { config, configDir, issues, output: null, written: false };
|
||||
}
|
||||
|
||||
if (issues.length) {
|
||||
const error = new Error("Конфигурация содержит ошибки:\n - " + issues.join("\n - "));
|
||||
error.issues = issues;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const renderIssues = await resolveImages(config, deck);
|
||||
if (renderIssues.length) {
|
||||
const error = new Error("Не удалось подготовить изображения:\n - " + renderIssues.join("\n - "));
|
||||
error.issues = renderIssues;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pptx = renderDeck(config, deck);
|
||||
const output = options.output
|
||||
? path.resolve(options.output)
|
||||
: path.resolve(configDir, config.presentation.output);
|
||||
await pptx.writeFile({ fileName: output });
|
||||
|
||||
return { config, configDir, issues, deck, output, written: true };
|
||||
}
|
||||
|
||||
module.exports = { build, buildDeck, collectIssues, checkImages, resolveSlideFile };
|
||||
96
pptx/src/cli.js
Normal file
96
pptx/src/cli.js
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const { build } = require("./build");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { config: "config.yaml" };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--config":
|
||||
case "-c":
|
||||
options.config = argv[++i];
|
||||
break;
|
||||
case "--output":
|
||||
case "-o":
|
||||
options.output = argv[++i];
|
||||
break;
|
||||
case "--check":
|
||||
options.check = true;
|
||||
break;
|
||||
case "--verbose":
|
||||
case "-v":
|
||||
options.verbose = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
case "--version":
|
||||
options.version = true;
|
||||
break;
|
||||
default:
|
||||
if (!arg.startsWith("-")) {
|
||||
options.config = arg;
|
||||
} else {
|
||||
console.warn(`Неизвестный аргумент: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`lab-gen-pptx — генератор презентаций PPTX из Markdown
|
||||
|
||||
Использование:
|
||||
lab-gen-pptx [config.yaml] [опции]
|
||||
|
||||
Опции:
|
||||
-c, --config <file> путь к config.yaml (по умолчанию config.yaml)
|
||||
-o, --output <file> переопределить имя выходного файла
|
||||
--check только проверить конфигурацию и ссылки
|
||||
-v, --verbose подробный вывод
|
||||
-h, --help показать справку
|
||||
--version показать версию
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) return printHelp();
|
||||
if (options.version) {
|
||||
const pkg = require("../package.json");
|
||||
console.log(pkg.version);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configPath = path.resolve(options.config);
|
||||
try {
|
||||
const result = await build({ config: configPath, check: options.check, output: options.output });
|
||||
const { config, issues } = result;
|
||||
|
||||
if (issues.length) {
|
||||
console.log("Проверка конфигурации:");
|
||||
issues.forEach((issue) => console.log(` - ${issue}`));
|
||||
} else {
|
||||
console.log("Проверка конфигурации: ошибок нет.");
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
console.log(`Слайдов в конфигурации: ${(config.slides || []).length}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (issues.length) process.exitCode = 1;
|
||||
else console.log(`Готово: ${result.output}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
main();
|
||||
195
pptx/src/config.js
Normal file
195
pptx/src/config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const DEFAULTS = {
|
||||
presentation: {
|
||||
output: "presentation.pptx",
|
||||
author: "Иванов И.И.",
|
||||
track: "Название ИОТ-трека",
|
||||
},
|
||||
theme: {
|
||||
width: 13.333,
|
||||
height: 7.5,
|
||||
font: "Elektra Text Pro",
|
||||
colors: {
|
||||
white: "FFFFFF",
|
||||
ink: "1F2933",
|
||||
brand: "1B87C9",
|
||||
muted: "6B7280",
|
||||
accent: "0E6BA8",
|
||||
tint: "EAF4FB",
|
||||
line: "BFD3DD",
|
||||
},
|
||||
backgrounds: {
|
||||
title: "content/assets/title-bg.jpeg",
|
||||
content: "content/assets/content-bg.jpg",
|
||||
final: "content/assets/final-bg.jpeg",
|
||||
},
|
||||
slideNumber: {
|
||||
show: true,
|
||||
x: 12.05,
|
||||
y: 6.86,
|
||||
w: 1.15,
|
||||
h: 0.42,
|
||||
fontSize: 12,
|
||||
color: "FFFFFF",
|
||||
align: "right",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
titleSlide: {
|
||||
title: { x: 6.7, y: 1.85, w: 5.95, h: 1.9, fontSize: 28, bold: false, align: "center", color: "white" },
|
||||
subtitle: { x: 6.7, y: 3.75, w: 5.95, h: 0.7, fontSize: 16, align: "center", color: "white" },
|
||||
body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: "center", color: "white" },
|
||||
},
|
||||
section: {
|
||||
title: { x: 2.0, y: 2.85, w: 9.35, h: 1.8, fontSize: 32, bold: true, align: "center", color: "white" },
|
||||
},
|
||||
content: {
|
||||
title: { x: 0.6, y: 0.06, w: 12.15, h: 0.66, fontSize: 20, bold: false, align: "center", color: "white" },
|
||||
body: { x: 0.75, y: 1.0, w: 11.85, h: 5.55, fontSize: 18, align: "left", color: "ink", valign: "top" },
|
||||
bulletSize: 18,
|
||||
subBulletSize: 16,
|
||||
},
|
||||
final: {
|
||||
title: { x: 6.7, y: 2.85, w: 5.95, h: 1.6, fontSize: 28, bold: true, align: "center", color: "white" },
|
||||
body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: "center", color: "white" },
|
||||
},
|
||||
},
|
||||
table: {
|
||||
fontSize: 12,
|
||||
headerFill: "1B87C9",
|
||||
headerColor: "FFFFFF",
|
||||
cellColor: "1F2933",
|
||||
borderColor: "BFD3DD",
|
||||
},
|
||||
callout: {
|
||||
fill: "EAF4FB",
|
||||
border: "1B87C9",
|
||||
titleColor: "0E6BA8",
|
||||
textColor: "1F2933",
|
||||
titleFontSize: 18,
|
||||
fontSize: 15,
|
||||
lineWidth: 1.25,
|
||||
},
|
||||
metrics: {
|
||||
fill: "1B87C9",
|
||||
valueColor: "FFFFFF",
|
||||
labelColor: "EAF4FB",
|
||||
valueFontSize: 30,
|
||||
labelFontSize: 13,
|
||||
gap: 0.2,
|
||||
height: 1.25,
|
||||
},
|
||||
flow: {
|
||||
fill: "EAF4FB",
|
||||
border: "1B87C9",
|
||||
textColor: "1F2933",
|
||||
arrowColor: "1B87C9",
|
||||
fontSize: 13,
|
||||
height: 1.0,
|
||||
gap: 0.4,
|
||||
},
|
||||
chart: {
|
||||
palette: ["1B87C9", "7FB2D9", "0E6BA8", "BFD3DD"],
|
||||
fontSize: 12,
|
||||
legend: true,
|
||||
height: 3.4,
|
||||
},
|
||||
plantuml: {
|
||||
enabled: true,
|
||||
java: "auto",
|
||||
jar: "tools/plantuml.jar",
|
||||
format: "png",
|
||||
charset: "UTF-8",
|
||||
cache: true,
|
||||
cacheDir: ".plantuml-cache",
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
content: {
|
||||
dir: "content",
|
||||
},
|
||||
slides: [],
|
||||
};
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepMerge(base, override) {
|
||||
if (!isPlainObject(base)) return override;
|
||||
const out = Array.isArray(base) ? base.slice() : { ...base };
|
||||
if (!isPlainObject(override)) return out;
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (isPlainObject(value) && isPlainObject(out[key])) {
|
||||
out[key] = deepMerge(out[key], value);
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveColor(value, colors) {
|
||||
if (typeof value !== "string") return value;
|
||||
if (colors && Object.prototype.hasOwnProperty.call(colors, value)) return colors[value];
|
||||
return value.replace(/^#/, "");
|
||||
}
|
||||
|
||||
function resolveConfigPaths(config, configDir) {
|
||||
const backgrounds = config.theme.backgrounds;
|
||||
for (const key of Object.keys(backgrounds)) {
|
||||
const value = backgrounds[key];
|
||||
if (typeof value === "string" && value.trim() && !path.isAbsolute(value)) {
|
||||
backgrounds[key] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
const plantuml = config.plantuml;
|
||||
if (plantuml) {
|
||||
for (const key of ["jar", "cacheDir"]) {
|
||||
const value = plantuml[key];
|
||||
if (typeof value === "string" && value.trim() && !path.isAbsolute(value)) {
|
||||
plantuml[key] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadConfig(configPath) {
|
||||
const abs = path.resolve(configPath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
throw new Error(`Не найден файл конфигурации: ${abs}`);
|
||||
}
|
||||
const configDir = path.dirname(abs);
|
||||
const parsed = yaml.load(fs.readFileSync(abs, "utf8")) || {};
|
||||
const config = deepMerge(DEFAULTS, parsed);
|
||||
config.content.dir = path.resolve(configDir, config.content.dir);
|
||||
resolveConfigPaths(config, configDir);
|
||||
return { config, configDir };
|
||||
}
|
||||
|
||||
function validateConfig(config) {
|
||||
const errors = [];
|
||||
if (!Array.isArray(config.slides) || config.slides.length === 0) {
|
||||
errors.push("Не задан список слайдов (slides) в config.yaml");
|
||||
} else {
|
||||
config.slides.forEach((slide, i) => {
|
||||
if (!slide || typeof slide !== "object") {
|
||||
errors.push(`Слайд #${i + 1}: некорректная запись`);
|
||||
return;
|
||||
}
|
||||
if (!slide.file) errors.push(`Слайд #${i + 1}: не указан файл (file)`);
|
||||
const type = slide.type || "content";
|
||||
if (!["title", "section", "content", "final"].includes(type)) {
|
||||
errors.push(`Слайд #${i + 1}: неизвестный тип "${type}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = { DEFAULTS, deepMerge, loadConfig, validateConfig, resolveColor };
|
||||
6
pptx/src/index.js
Normal file
6
pptx/src/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const { build } = require("./build");
|
||||
const { parseSlide } = require("./parse");
|
||||
|
||||
module.exports = { build, parseSlide };
|
||||
230
pptx/src/parse.js
Normal file
230
pptx/src/parse.js
Normal file
@@ -0,0 +1,230 @@
|
||||
"use strict";
|
||||
|
||||
function parseTable(lines) {
|
||||
const rows = lines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("|"))
|
||||
.map((line) => line.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()))
|
||||
.filter((cells) => !cells.every((c) => /^:?-{2,}:?$/.test(c)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function unquote(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
const match = trimmed.match(/^"([\s\S]*)"$/);
|
||||
return match ? match[1].trim() : trimmed;
|
||||
}
|
||||
|
||||
function parseMetrics(lines) {
|
||||
const items = [];
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
let value = "";
|
||||
let label = line;
|
||||
if (line.includes("|")) {
|
||||
const parts = line.split("|");
|
||||
value = parts[0];
|
||||
label = parts.slice(1).join("|");
|
||||
} else {
|
||||
const match = line.match(/^(.*?)\s+[—–-]\s+(.*)$/);
|
||||
if (match) {
|
||||
value = match[1];
|
||||
label = match[2];
|
||||
}
|
||||
}
|
||||
items.push({ value: String(value).trim(), label: String(label).trim() });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseFlow(lines) {
|
||||
const steps = [];
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").trim();
|
||||
if (!line) continue;
|
||||
for (const part of line.split(/->|→/)) {
|
||||
const step = part.trim();
|
||||
if (step) steps.push(step);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function parseTextBlocks(lines) {
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let bullets = [];
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join(" ").trim();
|
||||
if (text) blocks.push({ kind: "paragraph", text });
|
||||
paragraph = [];
|
||||
};
|
||||
const flushBullets = () => {
|
||||
if (bullets.length) blocks.push({ kind: "bullets", items: bullets });
|
||||
bullets = [];
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
|
||||
if (bullet) {
|
||||
flushParagraph();
|
||||
bullets.push({ level: Math.min(3, Math.floor(bullet[1].length / 2)), text: bullet[2].trim() });
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === "") {
|
||||
flushParagraph();
|
||||
flushBullets();
|
||||
continue;
|
||||
}
|
||||
flushBullets();
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
flushBullets();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const CHART_TYPES = new Set(["bar", "bar3d", "line", "pie", "doughnut", "area", "radar", "scatter", "bubble"]);
|
||||
|
||||
function buildChartBlock(rest, buf) {
|
||||
const typeMatch = rest.match(/\b(bar3d|bar|line|pie|doughnut|area|radar|scatter|bubble)\b/i);
|
||||
const titleMatch = rest.match(/"([^"]*)"/);
|
||||
return {
|
||||
kind: "chart",
|
||||
chartType: (typeMatch ? typeMatch[1] : "bar").toLowerCase(),
|
||||
title: titleMatch ? titleMatch[1] : "",
|
||||
rows: parseTable(buf),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSlide(markdown) {
|
||||
const lines = String(markdown).replace(/\r\n?/g, "\n").split("\n");
|
||||
const slide = { title: null, subtitle: null, blocks: [], notes: null };
|
||||
let paragraph = [];
|
||||
let bullets = [];
|
||||
let i = 0;
|
||||
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join(" ").trim();
|
||||
if (text) slide.blocks.push({ kind: "paragraph", text });
|
||||
paragraph = [];
|
||||
};
|
||||
const flushBullets = () => {
|
||||
if (bullets.length) slide.blocks.push({ kind: "bullets", items: bullets });
|
||||
bullets = [];
|
||||
};
|
||||
const flushAll = () => {
|
||||
flushParagraph();
|
||||
flushBullets();
|
||||
};
|
||||
|
||||
const pushDirective = (name, rest, buf) => {
|
||||
switch (name) {
|
||||
case "notes":
|
||||
slide.notes = buf.join("\n").trim();
|
||||
return;
|
||||
case "table": {
|
||||
const rows = parseTable(buf);
|
||||
if (rows.length) slide.blocks.push({ kind: "table", rows });
|
||||
return;
|
||||
}
|
||||
case "image": {
|
||||
const rest2 = String(rest || "").trim();
|
||||
const fileMatch = rest2.match(/^("[^"]*"|\S+)/);
|
||||
const heightMatch = rest2.match(/\b(?:h|height)=([0-9.]+)/i);
|
||||
const sideMatch = rest2.match(/\bside=(left|right)\b/i);
|
||||
slide.blocks.push({
|
||||
kind: "image",
|
||||
file: fileMatch ? unquote(fileMatch[1]) : rest2,
|
||||
height: heightMatch ? Number(heightMatch[1]) : null,
|
||||
side: sideMatch ? sideMatch[1].toLowerCase() : null,
|
||||
caption: buf.join(" ").trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "callout": {
|
||||
const sideMatch = rest.match(/\bside=(left|right)\b/i);
|
||||
const title = unquote(rest.replace(/\bside=(left|right)\b/i, "").trim());
|
||||
slide.blocks.push({
|
||||
kind: "callout",
|
||||
title,
|
||||
side: sideMatch ? sideMatch[1].toLowerCase() : null,
|
||||
blocks: parseTextBlocks(buf),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "metrics":
|
||||
slide.blocks.push({ kind: "metrics", items: parseMetrics(buf) });
|
||||
return;
|
||||
case "flow":
|
||||
slide.blocks.push({ kind: "flow", steps: parseFlow(buf) });
|
||||
return;
|
||||
case "chart":
|
||||
slide.blocks.push(buildChartBlock(rest, buf));
|
||||
return;
|
||||
default:
|
||||
for (const block of parseTextBlocks(buf)) slide.blocks.push(block);
|
||||
}
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const raw = lines[i];
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
|
||||
const directive = line.match(/^:::\s*([A-Za-z]+)\b\s*(.*)$/);
|
||||
if (directive) {
|
||||
const name = directive[1].toLowerCase();
|
||||
const rest = directive[2] || "";
|
||||
const buf = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
|
||||
buf.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
flushAll();
|
||||
pushDirective(name, rest, buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^#\s+/.test(line) && slide.title === null) {
|
||||
flushAll();
|
||||
slide.title = line.replace(/^#\s+/, "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^##\s+/.test(line) && slide.subtitle === null) {
|
||||
flushAll();
|
||||
slide.subtitle = line.replace(/^##\s+/, "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
|
||||
if (bullet) {
|
||||
flushParagraph();
|
||||
bullets.push({ level: Math.min(3, Math.floor(bullet[1].length / 2)), text: bullet[2].trim() });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
flushAll();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushBullets();
|
||||
paragraph.push(line.trim());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
flushAll();
|
||||
return slide;
|
||||
}
|
||||
|
||||
module.exports = { parseSlide, parseTable, parseTextBlocks, parseMetrics, parseFlow };
|
||||
174
pptx/src/plantuml.js
Normal file
174
pptx/src/plantuml.js
Normal file
@@ -0,0 +1,174 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set([".puml", ".plantuml", ".pu", ".iuml"]);
|
||||
|
||||
function isPlantUmlSource(filePath) {
|
||||
if (!filePath) return false;
|
||||
return SOURCE_EXTENSIONS.has(path.extname(String(filePath)).toLowerCase());
|
||||
}
|
||||
|
||||
function resolveJava(javaCfg) {
|
||||
if (javaCfg && javaCfg !== "auto") return javaCfg;
|
||||
const home = process.env.JAVA_HOME;
|
||||
if (home) {
|
||||
const exe = process.platform === "win32" ? "java.exe" : "java";
|
||||
const candidate = path.join(home, "bin", exe);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return "java";
|
||||
}
|
||||
|
||||
function runProcess(command, args, input, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, { windowsHide: true });
|
||||
} catch (err) {
|
||||
reject(new Error(`PlantUML: не удалось запустить "${command}": ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
|
||||
const finish = (fn, arg) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
fn(arg);
|
||||
};
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
child.kill();
|
||||
finish(reject, new Error(`PlantUML: превышено время ожидания (${timeoutMs} мс)`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
child.on("error", (err) => {
|
||||
finish(reject, new Error(`PlantUML: не удалось запустить "${command}": ${err.message}`));
|
||||
});
|
||||
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
||||
child.on("close", (code) => {
|
||||
finish(resolve, {
|
||||
code,
|
||||
stdout: Buffer.concat(stdout),
|
||||
stderr: Buffer.concat(stderr).toString("utf8").trim(),
|
||||
});
|
||||
});
|
||||
|
||||
child.stdin.end(input == null ? "" : input);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPlantUml(sourceBuffer, cfg) {
|
||||
if (!cfg.jar) throw new Error("PlantUML: не задан путь к plantuml.jar");
|
||||
if (!fs.existsSync(cfg.jar)) {
|
||||
throw new Error(`PlantUML: plantuml.jar не найден: ${cfg.jar}`);
|
||||
}
|
||||
const java = resolveJava(cfg.java);
|
||||
const args = [
|
||||
"-jar",
|
||||
cfg.jar,
|
||||
"-pipe",
|
||||
`-t${cfg.format || "png"}`,
|
||||
"-charset",
|
||||
cfg.charset || "UTF-8",
|
||||
"-failfast2",
|
||||
"-nometadata",
|
||||
];
|
||||
const result = await runProcess(java, args, sourceBuffer, cfg.timeoutMs);
|
||||
if (result.code !== 0 || result.stdout.length === 0) {
|
||||
const detail = result.stderr || `код выхода ${result.code}`;
|
||||
throw new Error(`PlantUML: ошибка рендеринга: ${detail}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function jarFingerprint(cfg) {
|
||||
try {
|
||||
const st = fs.statSync(cfg.jar);
|
||||
return `${st.size}:${Math.round(st.mtimeMs)}`;
|
||||
} catch {
|
||||
return "nojar";
|
||||
}
|
||||
}
|
||||
|
||||
function cacheFileName(sourceBuffer, cfg) {
|
||||
const hash = crypto
|
||||
.createHash("sha1")
|
||||
.update(sourceBuffer)
|
||||
.update(`|${cfg.format || "png"}`)
|
||||
.update(`|${jarFingerprint(cfg)}`)
|
||||
.digest("hex");
|
||||
return `${hash}.png`;
|
||||
}
|
||||
|
||||
async function renderCached(sourceBuffer, cfg) {
|
||||
const dir = cfg.cacheDir || path.join(os.tmpdir(), "lab-gen-pptx-plantuml");
|
||||
const target = path.join(dir, cacheFileName(sourceBuffer, cfg));
|
||||
|
||||
if (cfg.cache && fs.existsSync(target)) {
|
||||
return { buffer: fs.readFileSync(target), cachePath: target };
|
||||
}
|
||||
|
||||
const buffer = await renderPlantUml(sourceBuffer, cfg);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(target, buffer);
|
||||
return { buffer, cachePath: target };
|
||||
}
|
||||
|
||||
/**
|
||||
* Проставляет каждому блоку ::: image путь к готовому изображению.
|
||||
* Для исходников PlantUML рендерит PNG; для растровых — проверяет наличие.
|
||||
* @returns {Promise<string[]>} список проблем (ошибок/предупреждений)
|
||||
*/
|
||||
async function resolveImages(config, deck) {
|
||||
const issues = [];
|
||||
const cfg = config.plantuml || {};
|
||||
const contentDir = config.content.dir;
|
||||
|
||||
for (const entry of deck) {
|
||||
for (const block of entry.parsed.blocks) {
|
||||
if (block.kind !== "image" || !block.file) continue;
|
||||
const source = path.isAbsolute(block.file) ? block.file : path.resolve(contentDir, block.file);
|
||||
if (isPlantUmlSource(source)) {
|
||||
if (cfg.enabled === false) {
|
||||
issues.push(`PlantUML отключён, а файл требует рендеринга: ${block.file}`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(source)) {
|
||||
issues.push(`Не найден исходник PlantUML: ${block.file}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { cachePath } = await renderCached(fs.readFileSync(source), {
|
||||
...cfg,
|
||||
jar: cfg.jar,
|
||||
});
|
||||
block.renderFile = cachePath;
|
||||
} catch (err) {
|
||||
issues.push(`Рисунок "${block.file}": ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
if (!fs.existsSync(source)) {
|
||||
issues.push(`Не найден файл изображения: ${block.file}`);
|
||||
continue;
|
||||
}
|
||||
block.renderFile = source;
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
module.exports = { isPlantUmlSource, resolveJava, renderPlantUml, renderCached, resolveImages };
|
||||
778
pptx/src/render.js
Normal file
778
pptx/src/render.js
Normal file
@@ -0,0 +1,778 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const { resolveColor } = require("./config");
|
||||
|
||||
const MASTER_BY_TYPE = {
|
||||
title: "TITLE",
|
||||
section: "SECTION",
|
||||
content: "CONTENT",
|
||||
final: "FINAL",
|
||||
};
|
||||
|
||||
const MEDIA_KINDS = new Set(["table", "image", "chart", "flow", "metrics", "callout"]);
|
||||
|
||||
function fontInches(fontSize) {
|
||||
return (fontSize / 72) * 1.25;
|
||||
}
|
||||
|
||||
function estimateLines(text, widthIn, fontSize) {
|
||||
const charWidth = (fontSize / 72) * 0.52;
|
||||
const perLine = Math.max(8, Math.floor(widthIn / charWidth));
|
||||
return Math.max(1, Math.ceil(String(text).length / perLine));
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает inline-разметку: **жирный**, *курсив*, ==выделение==, `код`.
|
||||
* Возвращает массив runs для pptxgenjs.
|
||||
*/
|
||||
function inlineRuns(text, base, colors) {
|
||||
const runs = [];
|
||||
const pattern = /(\*\*[^*]+\*\*|==[^=]+==|\*[^*]+\*|`[^`]+`)/g;
|
||||
const src = String(text);
|
||||
let last = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(src)) !== null) {
|
||||
if (match.index > last) runs.push({ text: src.slice(last, match.index), options: { ...base } });
|
||||
const token = match[0];
|
||||
if (token.startsWith("**")) {
|
||||
runs.push({ text: token.slice(2, -2), options: { ...base, bold: true } });
|
||||
} else if (token.startsWith("==")) {
|
||||
runs.push({
|
||||
text: token.slice(2, -2),
|
||||
options: { ...base, bold: true, color: colors.highlight },
|
||||
});
|
||||
} else if (token.startsWith("`")) {
|
||||
runs.push({
|
||||
text: token.slice(1, -1),
|
||||
options: { ...base, fontFace: "Consolas", color: colors.muted },
|
||||
});
|
||||
} else {
|
||||
runs.push({ text: token.slice(1, -1), options: { ...base, italic: true } });
|
||||
}
|
||||
last = pattern.lastIndex;
|
||||
}
|
||||
if (last < src.length) runs.push({ text: src.slice(last), options: { ...base } });
|
||||
if (!runs.length) runs.push({ text: src, options: { ...base } });
|
||||
return runs;
|
||||
}
|
||||
|
||||
function colorScheme(config) {
|
||||
const c = config.theme.colors;
|
||||
return {
|
||||
ink: resolveColor(c.ink, c),
|
||||
brand: resolveColor(c.brand, c),
|
||||
highlight: resolveColor(c.accent || c.brand, c),
|
||||
muted: resolveColor(c.muted, c),
|
||||
tint: resolveColor(c.tint || c.brand, c),
|
||||
line: resolveColor(c.line || c.brand, c),
|
||||
};
|
||||
}
|
||||
|
||||
function defineMasters(pptx, config) {
|
||||
const { theme } = config;
|
||||
const master = (title, background) =>
|
||||
pptx.defineSlideMaster({
|
||||
title,
|
||||
background: { path: background },
|
||||
objects: [],
|
||||
});
|
||||
|
||||
master("TITLE", theme.backgrounds.title);
|
||||
master("SECTION", theme.backgrounds.title);
|
||||
master("CONTENT", theme.backgrounds.content);
|
||||
master("FINAL", theme.backgrounds.final);
|
||||
}
|
||||
|
||||
function addSlideNumber(slide, config, number, type) {
|
||||
const spec = config.theme.slideNumber;
|
||||
if (!spec.show) return;
|
||||
const colorKey = type === "content" ? spec.contentColor || spec.color : spec.color;
|
||||
slide.addText(String(number), {
|
||||
x: spec.x,
|
||||
y: spec.y,
|
||||
w: spec.w,
|
||||
h: spec.h,
|
||||
fontSize: spec.fontSize,
|
||||
color: resolveColor(colorKey, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
bold: !!spec.bold,
|
||||
align: spec.align,
|
||||
valign: "middle",
|
||||
margin: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function style(spec, config, extra = {}) {
|
||||
return {
|
||||
x: spec.x,
|
||||
y: spec.y,
|
||||
w: spec.w,
|
||||
h: spec.h,
|
||||
fontSize: spec.fontSize,
|
||||
bold: !!spec.bold,
|
||||
align: spec.align || "left",
|
||||
valign: spec.valign || "top",
|
||||
color: resolveColor(spec.color, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// ── титульный, разделительный, финальный слайды ───────────────────
|
||||
|
||||
function renderTitleSlide(slide, parsed, config) {
|
||||
const spec = config.text.titleSlide;
|
||||
if (parsed.title) slide.addText(parsed.title, style(spec.title, config));
|
||||
if (parsed.subtitle) slide.addText(parsed.subtitle, style(spec.subtitle, config));
|
||||
|
||||
const bodyLines = parsed.blocks
|
||||
.filter((b) => b.kind === "paragraph")
|
||||
.map((b) => b.text);
|
||||
if (!bodyLines.length) {
|
||||
if (config.presentation.author) bodyLines.push(config.presentation.author);
|
||||
if (config.presentation.track) bodyLines.push(config.presentation.track);
|
||||
}
|
||||
if (bodyLines.length) {
|
||||
slide.addText(bodyLines.join("\n"), style(spec.body, config));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSectionSlide(slide, parsed, config) {
|
||||
const spec = config.text.section;
|
||||
const title = parsed.title || "";
|
||||
slide.addText(title, style(spec.title, config, { fit: "shrink" }));
|
||||
if (parsed.subtitle) {
|
||||
slide.addText(
|
||||
parsed.subtitle,
|
||||
style(spec.title, config, {
|
||||
y: spec.title.y + spec.title.h,
|
||||
fontSize: spec.title.fontSize * 0.55,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFinalSlide(slide, parsed, config) {
|
||||
const spec = config.text.final;
|
||||
slide.addText(parsed.title || "Спасибо за внимание", style(spec.title, config));
|
||||
const bodyLines = parsed.blocks.filter((b) => b.kind === "paragraph").map((b) => b.text);
|
||||
if (bodyLines.length) slide.addText(bodyLines.join("\n"), style(spec.body, config));
|
||||
}
|
||||
|
||||
// ── текстовые блоки ───────────────────────────────────────────────
|
||||
//
|
||||
// pptxgenjs генерирует свойства абзаца для каждого run, поэтому список с
|
||||
// inline-форматированием внутри пункта рисуется вручную: маркер отдельным
|
||||
// run'ом, текст — с отступом и висячим выравниванием.
|
||||
|
||||
function renderParagraph(slide, block, config, spec, colors, cursor) {
|
||||
const base = {
|
||||
color: colors.ink,
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.bulletSize,
|
||||
};
|
||||
const runs = inlineRuns(block.text, base, colors);
|
||||
const height = estimateLines(block.text, spec.body.w, spec.bulletSize) * fontInches(spec.bulletSize) + 0.06;
|
||||
slide.addText(runs, {
|
||||
x: spec.body.x,
|
||||
y: cursor.y,
|
||||
w: spec.body.w,
|
||||
h: height,
|
||||
align: "left",
|
||||
valign: "top",
|
||||
fontFace: config.theme.font,
|
||||
margin: 0,
|
||||
});
|
||||
cursor.y += height + 0.04;
|
||||
}
|
||||
|
||||
function bulletGeometry(item, spec) {
|
||||
const size = item.level === 0 ? spec.bulletSize : spec.subBulletSize;
|
||||
const indent = 0.3 + item.level * 0.3;
|
||||
const textWidth = Math.max(1, spec.body.w - indent);
|
||||
return { size, indent, textWidth };
|
||||
}
|
||||
|
||||
function renderBullets(slide, items, config, spec, colors, cursor) {
|
||||
for (const item of items) {
|
||||
const { size, indent, textWidth } = bulletGeometry(item, spec);
|
||||
const glyph = item.level === 0 ? "•" : "–";
|
||||
const lines = estimateLines(item.text, textWidth, size);
|
||||
const height = lines * fontInches(size) + 0.03;
|
||||
|
||||
slide.addText(glyph, {
|
||||
x: spec.body.x + item.level * 0.3,
|
||||
y: cursor.y,
|
||||
w: indent,
|
||||
h: height,
|
||||
align: "left",
|
||||
valign: "top",
|
||||
color: colors.ink,
|
||||
fontFace: config.theme.font,
|
||||
fontSize: size,
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
const base = {
|
||||
color: colors.ink,
|
||||
fontFace: config.theme.font,
|
||||
fontSize: size,
|
||||
};
|
||||
const runs = inlineRuns(item.text, base, colors);
|
||||
slide.addText(runs, {
|
||||
x: spec.body.x + indent,
|
||||
y: cursor.y,
|
||||
w: textWidth,
|
||||
h: height,
|
||||
align: "left",
|
||||
valign: "top",
|
||||
fontFace: config.theme.font,
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
cursor.y += height + 0.07;
|
||||
}
|
||||
cursor.y += 0.05;
|
||||
}
|
||||
|
||||
function estimateBlocksHeight(blocks, config, spec) {
|
||||
let y = 0;
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "paragraph") {
|
||||
y += estimateLines(block.text, spec.body.w, spec.bulletSize) * fontInches(spec.bulletSize) + 0.1;
|
||||
} else if (block.kind === "bullets") {
|
||||
for (const item of block.items) {
|
||||
const { size, textWidth } = bulletGeometry(item, spec);
|
||||
y += estimateLines(item.text, textWidth, size) * fontInches(size) + 0.1;
|
||||
}
|
||||
y += 0.08;
|
||||
}
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
function renderTextBlocks(slide, blocks, config, cursor) {
|
||||
const spec = config.text.content;
|
||||
const colors = colorScheme(config);
|
||||
let rendered = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "paragraph") {
|
||||
renderParagraph(slide, block, config, spec, colors, cursor);
|
||||
rendered = true;
|
||||
} else if (block.kind === "bullets") {
|
||||
renderBullets(slide, block.items, config, spec, colors, cursor);
|
||||
rendered = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rendered) cursor.y += 0.06;
|
||||
}
|
||||
|
||||
// ── плашка-вывод ──────────────────────────────────────────────────
|
||||
|
||||
function calloutRuns(block, config, colors) {
|
||||
const spec = config.callout;
|
||||
const runs = [];
|
||||
if (block.title) {
|
||||
runs.push({
|
||||
text: block.title,
|
||||
options: {
|
||||
bold: true,
|
||||
breakLine: true,
|
||||
paraSpaceBefore: 0,
|
||||
color: resolveColor(spec.titleColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.titleFontSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const inner of block.blocks) {
|
||||
if (inner.kind === "paragraph") {
|
||||
const base = {
|
||||
color: resolveColor(spec.textColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.fontSize,
|
||||
};
|
||||
const line = inlineRuns(inner.text, base, colors);
|
||||
line[line.length - 1].options.breakLine = true;
|
||||
runs.push(...line);
|
||||
} else if (inner.kind === "bullets") {
|
||||
for (const item of inner.items) {
|
||||
const base = {
|
||||
color: resolveColor(spec.textColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.fontSize,
|
||||
};
|
||||
const prefix = `${" ".repeat(item.level)}• `;
|
||||
const line = inlineRuns(item.text, base, colors);
|
||||
line[0].text = prefix + line[0].text;
|
||||
line[line.length - 1].options.breakLine = true;
|
||||
runs.push(...line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function calloutHeight(block, config) {
|
||||
const spec = config.callout;
|
||||
const area = config.text.content.body;
|
||||
const textWidth = area.w - 1.0;
|
||||
const titleLines = block.title ? estimateLines(block.title, textWidth, spec.titleFontSize) : 0;
|
||||
let contentHeight = 0;
|
||||
for (const inner of block.blocks) {
|
||||
if (inner.kind === "paragraph") {
|
||||
contentHeight += estimateLines(inner.text, textWidth, spec.fontSize) * fontInches(spec.fontSize) + 0.06;
|
||||
} else if (inner.kind === "bullets") {
|
||||
for (const item of inner.items) {
|
||||
contentHeight += estimateLines(item.text, textWidth - 0.4, spec.fontSize) * fontInches(spec.fontSize) + 0.05;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.max(0.9, titleLines * fontInches(spec.titleFontSize) + (titleLines ? 0.1 : 0) + contentHeight + 0.34);
|
||||
}
|
||||
|
||||
function renderCallout(pptx, slide, block, config, cursor) {
|
||||
const area = config.text.content.body;
|
||||
const spec = config.callout;
|
||||
const colors = colorScheme(config);
|
||||
const runs = calloutRuns(block, config, colors);
|
||||
const height = calloutHeight(block, config);
|
||||
|
||||
slide.addText(runs, {
|
||||
x: area.x,
|
||||
y: cursor.y,
|
||||
w: area.w,
|
||||
h: height,
|
||||
shape: pptx.ShapeType.roundRect,
|
||||
fill: { color: resolveColor(spec.fill, config.theme.colors) },
|
||||
line: { color: resolveColor(spec.border, config.theme.colors), width: spec.lineWidth },
|
||||
align: "left",
|
||||
valign: "middle",
|
||||
margin: [10, 14, 10, 14],
|
||||
});
|
||||
cursor.y += height + 0.2;
|
||||
}
|
||||
|
||||
// ── карточки-метрики ──────────────────────────────────────────────
|
||||
|
||||
function renderMetrics(pptx, slide, block, config, cursor) {
|
||||
const items = (block.items || []).slice(0, 4);
|
||||
if (!items.length) return;
|
||||
const area = config.text.content.body;
|
||||
const spec = config.metrics;
|
||||
const n = items.length;
|
||||
const gap = spec.gap;
|
||||
const cardW = (area.w - gap * (n - 1)) / n;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const x = area.x + i * (cardW + gap);
|
||||
const runs = [
|
||||
{
|
||||
text: item.value || "—",
|
||||
options: {
|
||||
bold: true,
|
||||
breakLine: true,
|
||||
align: "center",
|
||||
color: resolveColor(spec.valueColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.valueFontSize,
|
||||
},
|
||||
},
|
||||
{
|
||||
text: item.label,
|
||||
options: {
|
||||
align: "center",
|
||||
color: resolveColor(spec.labelColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.labelFontSize,
|
||||
},
|
||||
},
|
||||
];
|
||||
slide.addText(runs, {
|
||||
x,
|
||||
y: cursor.y,
|
||||
w: cardW,
|
||||
h: spec.height,
|
||||
shape: pptx.ShapeType.roundRect,
|
||||
fill: { color: resolveColor(spec.fill, config.theme.colors) },
|
||||
line: { type: "none" },
|
||||
align: "center",
|
||||
valign: "middle",
|
||||
margin: [6, 8, 6, 8],
|
||||
});
|
||||
});
|
||||
|
||||
cursor.y += spec.height + 0.2;
|
||||
}
|
||||
|
||||
// ── нативная схема-цепочка ────────────────────────────────────────
|
||||
|
||||
function renderFlow(pptx, slide, block, config, cursor) {
|
||||
const steps = block.steps || [];
|
||||
if (!steps.length) return;
|
||||
const area = config.text.content.body;
|
||||
const spec = config.flow;
|
||||
const n = steps.length;
|
||||
const gap = spec.gap;
|
||||
const boxW = (area.w - gap * (n - 1)) / n;
|
||||
|
||||
if (boxW < 0.9) {
|
||||
renderFlowVertical(pptx, slide, steps, config, cursor);
|
||||
return;
|
||||
}
|
||||
|
||||
const h = spec.height;
|
||||
const arrowH = 0.24;
|
||||
steps.forEach((step, i) => {
|
||||
const x = area.x + i * (boxW + gap);
|
||||
slide.addText(step, {
|
||||
x,
|
||||
y: cursor.y,
|
||||
w: boxW,
|
||||
h,
|
||||
shape: pptx.ShapeType.roundRect,
|
||||
fill: { color: resolveColor(spec.fill, config.theme.colors) },
|
||||
line: { color: resolveColor(spec.border, config.theme.colors), width: 1 },
|
||||
align: "center",
|
||||
valign: "middle",
|
||||
color: resolveColor(spec.textColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.fontSize,
|
||||
margin: [3, 6, 3, 6],
|
||||
});
|
||||
if (i < n - 1) {
|
||||
slide.addShape(pptx.ShapeType.rightArrow, {
|
||||
x: x + boxW,
|
||||
y: cursor.y + h / 2 - arrowH / 2,
|
||||
w: gap,
|
||||
h: arrowH,
|
||||
fill: { color: resolveColor(spec.arrowColor, config.theme.colors) },
|
||||
line: { type: "none" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cursor.y += h + 0.2;
|
||||
}
|
||||
|
||||
function renderFlowVertical(pptx, slide, steps, config, cursor) {
|
||||
const area = config.text.content.body;
|
||||
const spec = config.flow;
|
||||
const h = 0.55;
|
||||
const arrowH = 0.22;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
slide.addText(steps[i], {
|
||||
x: area.x + area.w * 0.1,
|
||||
y: cursor.y,
|
||||
w: area.w * 0.8,
|
||||
h,
|
||||
shape: pptx.ShapeType.roundRect,
|
||||
fill: { color: resolveColor(spec.fill, config.theme.colors) },
|
||||
line: { color: resolveColor(spec.border, config.theme.colors), width: 1 },
|
||||
align: "center",
|
||||
valign: "middle",
|
||||
color: resolveColor(spec.textColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: spec.fontSize,
|
||||
margin: [3, 6, 3, 6],
|
||||
});
|
||||
if (i < steps.length - 1) {
|
||||
slide.addShape(pptx.ShapeType.downArrow, {
|
||||
x: area.x + area.w / 2 - arrowH / 2,
|
||||
y: cursor.y + h,
|
||||
w: arrowH,
|
||||
h: arrowH,
|
||||
fill: { color: resolveColor(spec.arrowColor, config.theme.colors) },
|
||||
line: { type: "none" },
|
||||
});
|
||||
}
|
||||
cursor.y += h + arrowH;
|
||||
}
|
||||
cursor.y += 0.05;
|
||||
}
|
||||
|
||||
// ── таблицы ───────────────────────────────────────────────────────
|
||||
|
||||
function colRatios(cols) {
|
||||
if (cols === 3) return [0.24, 0.34, 0.42];
|
||||
if (cols === 4) return [0.19, 0.28, 0.3, 0.23];
|
||||
return new Array(cols).fill(1 / cols);
|
||||
}
|
||||
|
||||
function renderTable(slide, block, config, cursor) {
|
||||
const t = config.table;
|
||||
const colors = colorScheme(config);
|
||||
const rows = block.rows.map((cells, r) =>
|
||||
cells.map((text) => {
|
||||
const base = {
|
||||
color: resolveColor(r === 0 ? t.headerColor : t.cellColor, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: t.fontSize,
|
||||
bold: r === 0,
|
||||
};
|
||||
return {
|
||||
text: inlineRuns(String(text), base, colors),
|
||||
options: {
|
||||
fill: r === 0 ? resolveColor(t.headerFill, config.theme.colors) : { color: "FFFFFF" },
|
||||
valign: "middle",
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
const area = config.text.content.body;
|
||||
const cols = block.rows[0].length;
|
||||
const rowH = cols >= 4 ? 0.5 : 0.42;
|
||||
const height = Math.min(area.y + area.h - cursor.y, block.rows.length * rowH);
|
||||
slide.addTable(rows, {
|
||||
x: area.x,
|
||||
y: cursor.y,
|
||||
w: area.w,
|
||||
colW: colRatios(cols).map((r) => r * area.w),
|
||||
rowH,
|
||||
border: { type: "solid", color: resolveColor(t.borderColor, config.theme.colors), pt: 1 },
|
||||
fontFace: config.theme.font,
|
||||
fontSize: t.fontSize,
|
||||
autoPage: false,
|
||||
});
|
||||
cursor.y += height + 0.15;
|
||||
}
|
||||
|
||||
// ── изображения ───────────────────────────────────────────────────
|
||||
|
||||
function pngDimensions(buffer) {
|
||||
if (buffer.length > 24 && buffer.toString("ascii", 1, 4) === "PNG") {
|
||||
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jpegDimensions(buffer) {
|
||||
let i = 2;
|
||||
while (i < buffer.length - 8) {
|
||||
if (buffer[i] !== 0xff) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const marker = buffer[i + 1];
|
||||
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
|
||||
return { width: buffer.readUInt16BE(i + 7), height: buffer.readUInt16BE(i + 5) };
|
||||
}
|
||||
if (marker >= 0xd0 && marker <= 0xd9) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const len = buffer.readUInt16BE(i + 2);
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function imageDimensions(file) {
|
||||
try {
|
||||
const buffer = fs.readFileSync(file);
|
||||
return pngDimensions(buffer) || jpegDimensions(buffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImage(slide, block, config, cursor) {
|
||||
const area = config.text.content.body;
|
||||
const file = block.renderFile || block.file;
|
||||
const reserve = block.caption ? 0.5 : 0.1;
|
||||
const maxW = area.w;
|
||||
let maxH = Math.max(0.8, area.y + area.h - cursor.y - reserve);
|
||||
if (block.height) maxH = Math.min(maxH, block.height);
|
||||
const dims = imageDimensions(file);
|
||||
|
||||
let w = maxW;
|
||||
let h = Math.min(maxH, maxW * 0.6);
|
||||
if (dims && dims.width && dims.height) {
|
||||
const scale = Math.min(maxW / dims.width, maxH / dims.height);
|
||||
w = dims.width * scale;
|
||||
h = dims.height * scale;
|
||||
}
|
||||
const x = area.x + (maxW - w) / 2;
|
||||
|
||||
slide.addImage({ path: file, x, y: cursor.y, w, h });
|
||||
cursor.y += h + 0.12;
|
||||
if (block.caption) {
|
||||
slide.addText(block.caption, {
|
||||
x: area.x,
|
||||
y: cursor.y,
|
||||
w: area.w,
|
||||
h: 0.4,
|
||||
fontSize: config.text.content.subBulletSize,
|
||||
italic: true,
|
||||
align: "center",
|
||||
color: resolveColor(config.theme.colors.muted, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
});
|
||||
cursor.y += 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
// ── графики ───────────────────────────────────────────────────────
|
||||
|
||||
function renderChart(pptx, slide, block, config, cursor) {
|
||||
const rows = block.rows || [];
|
||||
if (rows.length < 2 || rows[0].length < 2) return;
|
||||
const area = config.text.content.body;
|
||||
const spec = config.chart;
|
||||
|
||||
const header = rows[0];
|
||||
const body = rows.slice(1);
|
||||
const labels = body.map((r) => r[0]);
|
||||
const series = [];
|
||||
for (let j = 1; j < header.length; j++) {
|
||||
series.push({
|
||||
name: header[j],
|
||||
labels,
|
||||
values: body.map((r) => {
|
||||
const value = Number(String(r[j] ?? "").replace(/\s/g, "").replace(",", "."));
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let titleHeight = 0;
|
||||
if (block.title) {
|
||||
slide.addText(block.title, {
|
||||
x: area.x,
|
||||
y: cursor.y,
|
||||
w: area.w,
|
||||
h: 0.4,
|
||||
align: "left",
|
||||
valign: "middle",
|
||||
color: resolveColor(config.theme.colors.ink, config.theme.colors),
|
||||
fontFace: config.theme.font,
|
||||
fontSize: config.text.content.subBulletSize,
|
||||
bold: true,
|
||||
});
|
||||
titleHeight = 0.45;
|
||||
}
|
||||
|
||||
const type = pptx.ChartType[block.chartType] || pptx.ChartType.bar;
|
||||
const isPie = block.chartType === "pie" || block.chartType === "doughnut";
|
||||
const height = Math.min(area.y + area.h - cursor.y - titleHeight, spec.height);
|
||||
|
||||
slide.addChart(type, series, {
|
||||
x: area.x,
|
||||
y: cursor.y + titleHeight,
|
||||
w: area.w,
|
||||
h: height,
|
||||
showLegend: isPie ? true : series.length > 1 && spec.legend,
|
||||
legendPos: "b",
|
||||
legendFontFace: config.theme.font,
|
||||
legendFontSize: spec.fontSize,
|
||||
chartColors: spec.palette.map((c) => resolveColor(c, config.theme.colors)),
|
||||
catAxisLabelFontFace: config.theme.font,
|
||||
catAxisLabelFontSize: spec.fontSize,
|
||||
catAxisLabelColor: resolveColor(config.theme.colors.ink, config.theme.colors),
|
||||
valAxisLabelFontFace: config.theme.font,
|
||||
valAxisLabelFontSize: spec.fontSize,
|
||||
valAxisLabelColor: resolveColor(config.theme.colors.muted, config.theme.colors),
|
||||
dataLabelFontFace: config.theme.font,
|
||||
showValue: false,
|
||||
dataLabelColor: resolveColor(config.theme.colors.ink, config.theme.colors),
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
cursor.y += titleHeight + height + 0.2;
|
||||
}
|
||||
|
||||
// ── сборка слайда ─────────────────────────────────────────────────
|
||||
|
||||
function renderMediaBlock(pptx, slide, block, config, cursor) {
|
||||
if (block.kind === "table") renderTable(slide, block, config, cursor);
|
||||
else if (block.kind === "image") renderImage(slide, block, config, cursor);
|
||||
else if (block.kind === "chart") renderChart(pptx, slide, block, config, cursor);
|
||||
else if (block.kind === "flow") renderFlow(pptx, slide, block, config, cursor);
|
||||
else if (block.kind === "metrics") renderMetrics(pptx, slide, block, config, cursor);
|
||||
else if (block.kind === "callout") renderCallout(pptx, slide, block, config, cursor);
|
||||
}
|
||||
|
||||
function renderSideColumns(pptx, slide, blocks, config) {
|
||||
const area = config.text.content.body;
|
||||
const gap = 0.5;
|
||||
const leftW = area.w * 0.5 - gap / 2;
|
||||
const rightW = area.w - leftW - gap;
|
||||
const columns = {
|
||||
left: { x: area.x, y: area.y, w: leftW, h: area.h },
|
||||
right: { x: area.x + leftW + gap, y: area.y, w: rightW, h: area.h },
|
||||
};
|
||||
|
||||
for (const name of ["left", "right"]) {
|
||||
const colBlocks = blocks.filter((b) => b.side === name);
|
||||
if (!colBlocks.length) continue;
|
||||
const colConfig = {
|
||||
...config,
|
||||
text: { ...config.text, content: { ...config.text.content, body: columns[name] } },
|
||||
};
|
||||
const cursor = { y: columns[name].y };
|
||||
for (const block of colBlocks) renderMediaBlock(pptx, slide, block, colConfig, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContentSlide(pptx, slide, parsed, config) {
|
||||
const spec = config.text.content;
|
||||
if (parsed.title) slide.addText(parsed.title, style(spec.title, config));
|
||||
|
||||
const cursor = { y: spec.body.y };
|
||||
const sideBlocks = parsed.blocks.filter(
|
||||
(b) => (b.kind === "image" || b.kind === "callout") && b.side
|
||||
);
|
||||
const normalBlocks = parsed.blocks.filter((b) => !sideBlocks.includes(b));
|
||||
const mediaBlocks = normalBlocks.filter((b) => MEDIA_KINDS.has(b.kind));
|
||||
const textBlocks = normalBlocks.filter((b) => !MEDIA_KINDS.has(b.kind));
|
||||
|
||||
if (textBlocks.length && mediaBlocks.length) {
|
||||
const compressed = { ...spec.body, h: spec.body.h * 0.42 };
|
||||
const compressedConfig = {
|
||||
...config,
|
||||
text: { ...config.text, content: { ...spec, body: compressed } },
|
||||
};
|
||||
renderTextBlocks(slide, textBlocks, compressedConfig, cursor);
|
||||
cursor.y = compressed.y + compressed.h + 0.1;
|
||||
} else if (textBlocks.length) {
|
||||
renderTextBlocks(slide, textBlocks, config, cursor);
|
||||
}
|
||||
|
||||
for (const block of mediaBlocks) renderMediaBlock(pptx, slide, block, config, cursor);
|
||||
|
||||
if (sideBlocks.length) renderSideColumns(pptx, slide, sideBlocks, config);
|
||||
}
|
||||
|
||||
function renderDeck(config, deck) {
|
||||
const PptxGenJS = require("pptxgenjs");
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.defineLayout({ name: "SSAU", width: config.theme.width, height: config.theme.height });
|
||||
pptx.layout = "SSAU";
|
||||
pptx.author = config.presentation.author || "";
|
||||
pptx.title = config.presentation.output;
|
||||
defineMasters(pptx, config);
|
||||
|
||||
deck.forEach((entry, index) => {
|
||||
const type = entry.type || "content";
|
||||
const slide = pptx.addSlide({ masterName: MASTER_BY_TYPE[type] });
|
||||
const parsed = entry.parsed;
|
||||
|
||||
if (type === "title") renderTitleSlide(slide, parsed, config);
|
||||
else if (type === "section") renderSectionSlide(slide, parsed, config);
|
||||
else if (type === "final") renderFinalSlide(slide, parsed, config);
|
||||
else renderContentSlide(pptx, slide, parsed, config);
|
||||
|
||||
if (type === "content" || type === "section") {
|
||||
addSlideNumber(slide, config, index + 1, type);
|
||||
}
|
||||
|
||||
if (parsed.notes) slide.addNotes(parsed.notes);
|
||||
});
|
||||
|
||||
return pptx;
|
||||
}
|
||||
|
||||
module.exports = { renderDeck, MASTER_BY_TYPE };
|
||||
Reference in New Issue
Block a user