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>
185 lines
8.4 KiB
JavaScript
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/`);
|
|
}
|