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>
146 lines
7.0 KiB
JavaScript
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();
|
|
}
|