Files
HiveGrid-Marketing/webinar/skript-pdf.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

146 lines
7.0 KiB
JavaScript

// Macht aus Skript.md ein PDF in A4.
//
// Aufruf: node skript-pdf.mjs <ziel.pdf> [--quelle Skript-kurz.md] [--fuer-noah]
// --quelle andere Markdown-Datei aus diesem Ordner, Vorgabe Skript.md
// --fuer-noah lässt die Arbeitszeilen oben weg (wer schreibt, Grundlage)
//
// Chrome läuft wie in slides/exportieren.mjs über das DevTools-Protokoll:
// eine Instanz, am Ende samt Hilfsprozessen beendet, spätestens nach 60 s.
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HIER = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { marked } = require(join(HIER, "..", "Dashboard", "vendor", "marked.min.js"));
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const ziel = process.argv[2];
if (!ziel) { console.error("Aufruf: node skript-pdf.mjs <ziel.pdf> [--fuer-noah]"); process.exit(1); }
const fuerNoah = process.argv.includes("--fuer-noah");
const qi = process.argv.indexOf("--quelle");
const quelle = qi > 0 ? process.argv[qi + 1] : "Skript.md";
let text = readFileSync(join(HIER, quelle), "utf-8");
if (fuerNoah) {
text = text
.split("\n")
.filter((z) => !z.startsWith("**Grundlage:**"))
.map((z) => z.startsWith("**Stand:**") ? z.replace(/(Entwurf \d+\.).*/, "$1") : z)
.join("\n");
}
// Jeder Slide-Block (Überschrift, Knackpunkt, Sätze) bleibt auf einer Seite,
// und ein Teil beginnt nie mit seinem Kasten allein unten auf der Seite.
// Chrome hält sich beim Drucken nicht verlässlich an break-after: avoid,
// an break-inside: avoid auf einem Container schon.
function bloecke(html) {
html = html.replace(/(<h3[^>]*>[\s\S]*?<\/h3>\s*<p>[\s\S]*?<\/p>\s*<ul>[\s\S]*?<\/ul>)/g, '<div class="block">$1</div>');
html = html.replace(/(<h2[^>]*>[\s\S]*?<\/h2>\s*<blockquote>[\s\S]*?<\/blockquote>\s*<div class="block">[\s\S]*?<\/div>)/g, '<div class="block">$1</div>');
return html;
}
const logo = pathToFileURL(join(HIER, "slides", "bilder", "logo.png")).href;
const seite = `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><title>HiveGrid Webinar: Skript</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Mona+Sans:wght@400;500;600&display=swap">
<style>
@page { size: A4; margin: 18mm 18mm 20mm 18mm; }
body { font-family: 'Mona Sans', Arial, sans-serif; font-size: 10.5pt; line-height: 1.5; color: #24242B; margin: 0; }
.logo { height: 16px; margin: 0 0 10mm; display: block; }
h1 { font-size: 24pt; font-weight: 600; line-height: 1.15; margin: 0 0 5mm; }
h2 { font-size: 16pt; font-weight: 600; line-height: 1.2; margin: 9mm 0 3mm; break-after: avoid; }
h3 { font-size: 12pt; font-weight: 600; margin: 6mm 0 2mm; break-after: avoid; }
p { margin: 0 0 2.5mm; }
p:has(+ ul), p:has(+ ol) { break-after: avoid; }
ul, ol { margin: 0 0 3mm; padding-left: 5mm; }
li { margin: 0 0 1mm; }
blockquote { background: #F5F4F4; margin: 2mm 0 3mm; padding: 3mm 4mm; break-inside: avoid;
clip-path: polygon(6px 0, calc(100% - 6px) 0, 100% 6px, 100% calc(100% - 6px), calc(100% - 6px) 100%, 6px 100%, 0 calc(100% - 6px), 0 6px); }
blockquote p { margin: 0; }
.block { break-inside: avoid; }
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin: 2mm 0 4mm; }
th, td { text-align: left; vertical-align: top; padding: 1.8mm 2mm; border-bottom: 1px solid #DCDCDE; }
th { font-weight: 600; border-bottom: 1.5px solid #24242B; }
tr { break-inside: avoid; }
hr { border: 0; border-top: 1px solid #DCDCDE; margin: 7mm 0; }
code { font-family: inherit; background: #F0F0F1; padding: 0 3px; }
strong { font-weight: 600; }
</style></head><body>
<img class="logo" src="${logo}" alt="HiveGrid">
${bloecke(marked.parse(text))}
</body></html>`;
const arbeit = mkdtempSync(join(tmpdir(), "hivegrid-skript-"));
const htmlDatei = join(arbeit, "skript.html");
writeFileSync(htmlDatei, seite);
const profil = mkdtempSync(join(tmpdir(), "hivegrid-skript-chrome-"));
const chrome = spawn(CHROME, [
"--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
"--allow-file-access-from-files", "--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 });
rmSync(arbeit, { 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 60 Sekunden erreicht"); beenden(); }, 60000);
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);
const geladen = warten((m) => m.method === "Page.loadEventFired" && m.sessionId === sessionId);
await senden("Page.navigate", { url: pathToFileURL(htmlDatei).href }, sessionId);
await geladen;
await senden("Runtime.evaluate", { expression: "document.fonts.ready.then(() => true)", awaitPromise: true }, sessionId);
const { data } = await senden("Page.printToPDF", {
printBackground: true, preferCSSPageSize: true, displayHeaderFooter: true,
headerTemplate: "<div></div>",
footerTemplate: '<div style="width:100%;font-size:8px;font-family:Arial,sans-serif;color:#6D6D71;text-align:center">Webinar-Skript, Seite <span class="pageNumber"></span> von <span class="totalPages"></span></div>',
}, sessionId);
writeFileSync(resolve(ziel), Buffer.from(data, "base64"));
console.log("Skript-PDF:", resolve(ziel));
} finally {
clearTimeout(notbremse);
beenden();
}