Files
HiveGrid-Marketing/webinar/slides/exportieren.mjs
T
dejanandClaude Opus 5.5 686ee36b4f HiveGrid Marketing: Hub mit Strategie, Redaktionsplan, Dashboard, Produktionsvorgaben und Webinar
Stand 16.10.2026. Enthält das Marketing-Dashboard, die Daten in daten/,
die Produktionsvorgaben, Reels und das Webinar mit Skript, Sprechzettel
und Präsentation (HTML, PowerPoint, PDF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:09:07 +02:00

185 lines
8.4 KiB
JavaScript

// Exportiert die Webinar-Slides als PDF und als PowerPoint.
//
// Grundlage ist .druck.html, die bauen.py aus quelle/ erzeugt. Chrome rendert
// jede Slide in 1920 x 1080. Daraus entstehen:
// HiveGrid-Webinar.pdf jede Slide eine Seite
// HiveGrid-Webinar.pptx jede Slide ein Bild, dazu die Sprechernotizen
// Die PowerPoint öffnet in Keynote und PowerPoint, auch ohne die Schrift
// Mona Sans, weil die Slides als Bilder drinstecken.
//
// Aufruf: npm run export (baut vorher mit bauen.py neu)
//
// Chrome läuft über das DevTools-Protokoll, wie in werkzeuge/bauen.mjs:
// genau eine Instanz, die am Ende samt Hilfsprozessen beendet wird,
// spätestens nach 120 Sekunden.
import { spawn } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import pptxgen from "pptxgenjs";
import JSZip from "jszip";
const HIER = dirname(fileURLToPath(import.meta.url));
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
// Für Vorschauen lassen sich Eingabe und Bilderordner umlenken. Mit
// NUR_BILDER=1 entstehen nur die PNGs, PDF und PowerPoint bleiben unberührt.
const DRUCK = process.env.DRUCK || join(HIER, ".druck.html");
const BILDER = process.env.BILDER || join(HIER, "export");
const NUR_BILDER = process.env.NUR_BILDER === "1";
const PDF = join(HIER, "HiveGrid-Webinar.pdf");
const PPTX = join(HIER, "HiveGrid-Webinar.pptx");
async function rendern() {
const profil = mkdtempSync(join(tmpdir(), "hivegrid-slides-"));
const chrome = spawn(CHROME, [
"--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
"--hide-scrollbars", "--remote-debugging-pipe", `--user-data-dir=${profil}`, "about:blank",
], { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"], detached: true });
let beendet = false;
const beenden = () => {
if (beendet) return;
beendet = true;
try { process.kill(-chrome.pid, "SIGKILL"); } catch { /* schon weg */ }
rmSync(profil, { recursive: true, force: true });
};
const offen = new Map();
const warter = [];
const abbrechen = (grund) => {
for (const f of offen.values()) f({ error: { message: grund } });
offen.clear();
for (const w of warter.splice(0)) w.nein(new Error(grund));
};
const notbremse = setTimeout(() => { abbrechen("Zeitgrenze von 120 Sekunden erreicht"); beenden(); }, 120000);
chrome.on("exit", () => abbrechen("Chrome wurde beendet"));
try {
const raus = chrome.stdio[3];
let puffer = "";
let n = 0;
chrome.stdio[4].on("data", (stueck) => {
puffer += stueck.toString();
let i;
while ((i = puffer.indexOf("\0")) >= 0) {
const m = JSON.parse(puffer.slice(0, i));
puffer = puffer.slice(i + 1);
if (m.id && offen.has(m.id)) { offen.get(m.id)(m); offen.delete(m.id); continue; }
const w = warter.findIndex((x) => x.passt(m));
if (w >= 0) warter.splice(w, 1)[0].ok(m);
}
});
const senden = (method, params = {}, sessionId) => new Promise((ok, nein) => {
const id = ++n;
offen.set(id, (m) => (m.error ? nein(new Error(`${method}: ${m.error.message}`)) : ok(m.result)));
raus.write(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + "\0");
});
const warten = (passt) => new Promise((ok, nein) => warter.push({ passt, ok, nein }));
const { targetId } = await senden("Target.createTarget", { url: "about:blank" });
const { sessionId } = await senden("Target.attachToTarget", { targetId, flatten: true });
await senden("Page.enable", {}, sessionId);
await senden("Emulation.setDeviceMetricsOverride",
{ width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false }, sessionId);
const geladen = warten((m) => m.method === "Page.loadEventFired" && m.sessionId === sessionId);
await senden("Page.navigate", { url: pathToFileURL(DRUCK).href }, sessionId);
await geladen;
await senden("Runtime.evaluate", {
expression: "Promise.all([document.fonts.ready, ...[...document.images].map(i => i.complete ? 1 : new Promise(r => { i.onload = i.onerror = r; }))]).then(() => document.querySelectorAll('.seite').length)",
awaitPromise: true, returnByValue: true,
}, sessionId).then((r) => { rendern.anzahl = r.result.value; });
if (!NUR_BILDER) {
const { data } = await senden("Page.printToPDF", { printBackground: true, preferCSSPageSize: true }, sessionId);
writeFileSync(PDF, Buffer.from(data, "base64"));
}
rmSync(BILDER, { recursive: true, force: true });
mkdirSync(BILDER, { recursive: true });
const dateien = [];
for (let i = 0; i < rendern.anzahl; i++) {
const bild = await senden("Page.captureScreenshot", {
format: "png", captureBeyondViewport: true,
clip: { x: 0, y: i * 1080, width: 1920, height: 1080, scale: 1 },
}, sessionId);
const datei = join(BILDER, `${String(i + 1).padStart(2, "0")}.png`);
writeFileSync(datei, Buffer.from(bild.data, "base64"));
dateien.push(datei);
}
return dateien;
} finally {
clearTimeout(notbremse);
beenden();
}
}
function notizen() {
const deck = JSON.parse(readFileSync(join(HIER, "quelle", "deck.json"), "utf-8"));
const abschnitte = Object.values(deck.sections || {});
return deck.order.map((sid) => {
const html = readFileSync(join(HIER, "quelle", "slides", `${sid}.html`), "utf-8");
const m = html.match(/<aside>([\s\S]*?)<\/aside>/);
const abschnitt = abschnitte.find((a) => a.start === sid);
return { sid, notiz: m ? m[1].trim() : "", abschnitt: abschnitt ? abschnitt.description : null, titel: deck.title };
});
}
async function powerpoint(bilder, infos) {
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.title = infos[0]?.titel || "HiveGrid Webinar";
pres.author = "HiveGrid";
let abschnitt = null;
bilder.forEach((datei, i) => {
const info = infos[i] || {};
if (info.abschnitt) {
abschnitt = info.abschnitt.split(":")[0];
pres.addSection({ title: abschnitt });
}
const slide = pres.addSlide(abschnitt ? { sectionTitle: abschnitt } : {});
slide.background = { color: "FAFAFA" };
slide.addImage({ path: datei, x: 0, y: 0, w: 10, h: 5.625, altText: `Slide ${i + 1}` });
if (info.notiz) slide.addNotes(info.notiz);
});
await pres.writeFile({ fileName: PPTX });
await uebergaengeSetzen(infos);
}
// Übergänge wie im Online-Deck: einblenden. Nach Slides aus uebergaenge.json
// ("hoch") schiebt sich die nächste von unten hoch, damit das Wabenmuster
// über die Kante weiterläuft. pptxgenjs kann keine Übergänge, deshalb kommen
// sie danach ins XML, direkt hinter clrMapOvr, wo PowerPoint sie erwartet.
async function uebergaengeSetzen(infos) {
const hoch = JSON.parse(readFileSync(join(HIER, "uebergaenge.json"), "utf-8")).hoch || [];
const zip = await JSZip.loadAsync(readFileSync(PPTX));
for (let i = 0; i < infos.length; i++) {
const pfad = `ppt/slides/slide${i + 1}.xml`;
const xml = await zip.file(pfad)?.async("string");
if (!xml || xml.includes("<p:transition")) continue;
const uebergang = i > 0 && hoch.includes(infos[i - 1].sid)
? '<p:transition spd="slow"><p:push dir="u"/></p:transition>'
: '<p:transition spd="med"><p:fade/></p:transition>';
zip.file(pfad, xml.replace("</p:clrMapOvr>", "</p:clrMapOvr>" + uebergang));
}
// Notizen: pptxgenjs schreibt Zeilenumbrüche in einen einzigen Absatz, den
// PowerPoint als eine Zeile zeigt. Jede Zeile wird ein eigener Absatz.
for (const pfad of Object.keys(zip.files).filter((n) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(n))) {
const xml = await zip.file(pfad).async("string");
const neu = xml.replace(/<a:p><a:r>(<a:rPr[^>]*\/>)<a:t>([^<]*)<\/a:t><\/a:r>(<a:endParaRPr[^>]*\/>)?<\/a:p>/g, (ganz, rpr, text) => {
if (!/\r?\n/.test(text)) return ganz;
return text.split(/\r?\n/).map((zeile) => `<a:p><a:r>${rpr}<a:t>${zeile}</a:t></a:r></a:p>`).join("");
});
if (neu !== xml) zip.file(pfad, neu);
}
writeFileSync(PPTX, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
}
const bilder = await rendern();
if (NUR_BILDER) {
console.log(`${bilder.length} Bilder in ${BILDER}`);
} else {
await powerpoint(bilder, notizen());
console.log(`${bilder.length} Slides: HiveGrid-Webinar.pdf, HiveGrid-Webinar.pptx, Bilder in export/`);
}