This commit is contained in:
2026-10-08 06:49:13 +04:00
parent 41d434ef97
commit 35611e2a91
84 changed files with 3430 additions and 375 deletions

105
pptx/src/build.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };