2026-09-07 12:21:53 +02:00
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const puppeteer = require('puppeteer');
|
|
|
|
|
const storage = require('./utils/storage');
|
|
|
|
|
|
|
|
|
|
function fileToDataUri(fullPath) {
|
|
|
|
|
try {
|
|
|
|
|
const buf = fs.readFileSync(fullPath);
|
|
|
|
|
const ext = path.extname(fullPath).toLowerCase().replace('.', '');
|
|
|
|
|
const mime = { jpg: 'jpeg', jpeg: 'jpeg', png: 'png', gif: 'gif', webp: 'webp' }[ext] || 'jpeg';
|
|
|
|
|
return `data:image/${mime};base64,${buf.toString('base64')}`;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function esc(str) {
|
|
|
|
|
return String(str || '').replace(/[&<>"']/g, (c) => (
|
|
|
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ROTATIONS = [-4, 3, -2, 5, -3, 2, -5, 4];
|
2026-09-07 13:00:59 +02:00
|
|
|
const COVER_MAX_STICKERS = 30;
|
|
|
|
|
|
|
|
|
|
// Gleicher deterministischer Pseudo-Zufallsgenerator wie im Buch-Viewer (book.js)
|
|
|
|
|
function seededRandom(seed) {
|
|
|
|
|
let t = (seed += 0x6d2b79f5);
|
|
|
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
|
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
|
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectStickerImages(friends) {
|
|
|
|
|
const imgs = [];
|
|
|
|
|
friends.forEach((f) => {
|
|
|
|
|
const uploadsDir = path.join(storage.friendFolderPath(f.folderName), 'uploads');
|
|
|
|
|
if (f.profileImage) imgs.push(path.join(uploadsDir, f.profileImage));
|
|
|
|
|
(f.blocks || []).forEach((b) => {
|
|
|
|
|
if (b.type === 'image' && b.filename) imgs.push(path.join(uploadsDir, b.filename));
|
|
|
|
|
if (b.type === 'audio' && b.coverFilename) imgs.push(path.join(uploadsDir, b.coverFilename));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return imgs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildCoverPageHtml(title, friends) {
|
|
|
|
|
let files = collectStickerImages(friends);
|
|
|
|
|
if (files.length > COVER_MAX_STICKERS) {
|
|
|
|
|
const step = files.length / COVER_MAX_STICKERS;
|
|
|
|
|
files = Array.from({ length: COVER_MAX_STICKERS }, (_, i) => files[Math.floor(i * step)]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(files.length * 1.6)) || 3));
|
|
|
|
|
const rows = Math.max(1, Math.ceil(files.length / cols));
|
|
|
|
|
|
|
|
|
|
const stickers = files
|
|
|
|
|
.map((filePath, i) => {
|
|
|
|
|
const uri = fileToDataUri(filePath);
|
|
|
|
|
if (!uri) return '';
|
|
|
|
|
const cellX = i % cols;
|
|
|
|
|
const cellY = Math.floor(i / cols);
|
|
|
|
|
const rx = seededRandom(i * 13 + 1);
|
|
|
|
|
const ry = seededRandom(i * 17 + 2);
|
|
|
|
|
const rr = seededRandom(i * 7 + 3);
|
|
|
|
|
const rs = seededRandom(i * 5 + 4);
|
|
|
|
|
const leftPct = ((cellX + 0.1 + rx * 0.8) / cols) * 100;
|
|
|
|
|
const topPct = ((cellY + 0.1 + ry * 0.8) / rows) * 100;
|
|
|
|
|
const rot = (rr * 50 - 25).toFixed(1);
|
|
|
|
|
const sizeMm = Math.round(28 + rs * 20);
|
|
|
|
|
return `<img class="cover-sticker" style="left:${leftPct}%; top:${topPct}%; width:${sizeMm}mm; height:${sizeMm}mm; transform: rotate(${rot}deg);" src="${uri}" />`;
|
|
|
|
|
})
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<section class="page cover-page">
|
|
|
|
|
<div class="cover-stickers">${stickers}</div>
|
|
|
|
|
<div class="cover-title-wrap">
|
|
|
|
|
<div class="tape" style="position:static; margin: 0 auto 6mm; transform: rotate(-4deg);"></div>
|
|
|
|
|
<h1 class="cover-title">${esc(title)}</h1>
|
|
|
|
|
</div>
|
|
|
|
|
</section>`;
|
|
|
|
|
}
|
2026-09-07 12:21:53 +02:00
|
|
|
|
|
|
|
|
function buildFriendPageHtml(friend, folder) {
|
|
|
|
|
const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads');
|
|
|
|
|
const profileUri = friend.profileImage ? fileToDataUri(path.join(uploadsDir, friend.profileImage)) : null;
|
|
|
|
|
|
|
|
|
|
const steckbriefRows = Object.entries(friend.steckbrief || {})
|
|
|
|
|
.filter(([, v]) => v && String(v).trim())
|
|
|
|
|
.map(([k, v]) => `<div class="sb-row"><span class="sb-key">${esc(k)}</span><span class="sb-val">${esc(v)}</span></div>`)
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
const blocks = [...(friend.blocks || [])].sort((a, b) => a.order - b.order);
|
|
|
|
|
const blockHtml = blocks
|
|
|
|
|
.map((b, i) => {
|
|
|
|
|
const rot = ROTATIONS[i % ROTATIONS.length];
|
|
|
|
|
if (b.type === 'text') {
|
|
|
|
|
return `<div class="pdf-block text-block" style="transform: rotate(${rot * 0.4}deg)">
|
|
|
|
|
<p>${esc(b.content).replace(/\n/g, '<br/>')}</p>
|
|
|
|
|
${b.caption ? `<div class="cap">${esc(b.caption)}</div>` : ''}
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
if (b.type === 'image') {
|
|
|
|
|
const uri = fileToDataUri(path.join(uploadsDir, b.filename));
|
|
|
|
|
if (!uri) return '';
|
|
|
|
|
return `<div class="pdf-block image-block polaroid" style="transform: rotate(${rot}deg)">
|
|
|
|
|
<img src="${uri}" />
|
|
|
|
|
${b.caption ? `<div class="cap">${esc(b.caption)}</div>` : ''}
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
if (b.type === 'audio') {
|
2026-09-07 12:46:58 +02:00
|
|
|
const coverUri = b.coverFilename ? fileToDataUri(path.join(uploadsDir, b.coverFilename)) : null;
|
2026-09-07 12:21:53 +02:00
|
|
|
return `<div class="pdf-block audio-block" style="transform: rotate(${rot * 0.5}deg)">
|
2026-09-07 12:46:58 +02:00
|
|
|
${coverUri ? `<img class="audio-cover" src="${coverUri}" />` : ''}
|
2026-09-07 12:21:53 +02:00
|
|
|
<div class="audio-note">🎵 Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}<br/><small>(im Web-Freundesbuch anhörbar)</small></div>
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<section class="page">
|
|
|
|
|
<div class="tape tape-tl"></div><div class="tape tape-tr"></div>
|
|
|
|
|
<header class="page-head">
|
|
|
|
|
${profileUri ? `<img class="profile-pic" src="${profileUri}" />` : '<div class="profile-pic placeholder">?</div>'}
|
|
|
|
|
<h1>${esc(friend.name)}</h1>
|
|
|
|
|
</header>
|
|
|
|
|
<div class="steckbrief">
|
|
|
|
|
<h2>Steckbrief</h2>
|
|
|
|
|
${steckbriefRows || '<p class="muted">Noch nichts eingetragen.</p>'}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="blocks">
|
|
|
|
|
${blockHtml || '<p class="muted">Noch keine Erinnerungen hinzugefügt.</p>'}
|
|
|
|
|
</div>
|
|
|
|
|
</section>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 13:00:59 +02:00
|
|
|
function buildFullHtml(friends, bookTitle) {
|
|
|
|
|
const coverPage = buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends);
|
2026-09-07 12:21:53 +02:00
|
|
|
const pages = friends
|
|
|
|
|
.map((f) => buildFriendPageHtml(f, f.folderName))
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html lang="de">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<style>
|
|
|
|
|
@page { size: A4; margin: 0; }
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-family: 'Comic Sans MS', 'Segoe Print', 'Bradley Hand', cursive, sans-serif;
|
|
|
|
|
background: #faf3e6;
|
|
|
|
|
color: #3a3350;
|
|
|
|
|
}
|
|
|
|
|
.page {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 210mm;
|
|
|
|
|
min-height: 297mm;
|
|
|
|
|
padding: 18mm 16mm;
|
|
|
|
|
page-break-after: always;
|
|
|
|
|
background: #faf3e6;
|
|
|
|
|
background-image: radial-gradient(circle at 10% 10%, rgba(232,168,191,0.15), transparent 40%),
|
|
|
|
|
radial-gradient(circle at 90% 85%, rgba(168,213,186,0.18), transparent 45%);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.page:last-child { page-break-after: auto; }
|
|
|
|
|
.tape { position: absolute; width: 70px; height: 26px; background: rgba(232,184,75,0.55); }
|
|
|
|
|
.tape-tl { top: 6mm; left: 20mm; transform: rotate(-8deg); }
|
|
|
|
|
.tape-tr { top: 6mm; right: 20mm; transform: rotate(9deg); }
|
|
|
|
|
.page-head { text-align: center; margin-bottom: 8mm; }
|
|
|
|
|
.profile-pic {
|
|
|
|
|
width: 42mm; height: 42mm; object-fit: cover; border-radius: 50%;
|
|
|
|
|
border: 6px solid #fff; box-shadow: 0 4px 14px rgba(0,0,0,0.18);
|
|
|
|
|
transform: rotate(-3deg);
|
|
|
|
|
}
|
|
|
|
|
.profile-pic.placeholder {
|
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
|
font-size: 30px; color: #b9a9d8; background: #fff;
|
|
|
|
|
}
|
|
|
|
|
.page-head h1 {
|
|
|
|
|
font-size: 34px; margin: 6mm 0 0; color: #6b4f8c;
|
|
|
|
|
text-shadow: 1px 1px 0 rgba(255,255,255,0.6);
|
|
|
|
|
}
|
|
|
|
|
.steckbrief {
|
|
|
|
|
background: #fffdf7; border: 2px dashed #d9b568; border-radius: 10px;
|
|
|
|
|
padding: 6mm 8mm; margin-bottom: 8mm; transform: rotate(-0.6deg);
|
|
|
|
|
}
|
|
|
|
|
.steckbrief h2 { margin: 0 0 4mm; font-size: 20px; color: #b5652f; }
|
|
|
|
|
.sb-row { display: flex; gap: 6px; font-size: 13px; margin-bottom: 2mm; font-family: Georgia, serif; }
|
|
|
|
|
.sb-key { font-weight: bold; min-width: 32mm; color: #6b4f8c; }
|
|
|
|
|
.muted { color: #9a8fb0; font-style: italic; font-size: 12px; font-family: Georgia, serif; }
|
|
|
|
|
.blocks { display: flex; flex-wrap: wrap; gap: 6mm; }
|
|
|
|
|
.pdf-block { background: #fff; padding: 4mm; box-shadow: 0 3px 10px rgba(0,0,0,0.15); }
|
|
|
|
|
.polaroid { padding: 4mm 4mm 10mm; width: 58mm; }
|
|
|
|
|
.polaroid img { width: 100%; height: 48mm; object-fit: cover; display: block; }
|
|
|
|
|
.text-block { width: 58mm; min-height: 40mm; background: #fef7d6; font-family: Georgia, serif; font-size: 12px; }
|
2026-09-07 12:46:58 +02:00
|
|
|
.audio-block { width: 58mm; min-height: 30mm; background: #e8f3ec; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; font-size: 13px; padding: 3mm; }
|
|
|
|
|
.audio-cover { width: 100%; height: 30mm; object-fit: cover; border-radius: 4px; margin-bottom: 2mm; }
|
2026-09-07 12:21:53 +02:00
|
|
|
.cap { font-family: Georgia, serif; font-size: 11px; color: #6b4f8c; margin-top: 2mm; text-align: center; }
|
2026-09-07 13:00:59 +02:00
|
|
|
|
|
|
|
|
/* Deckblatt */
|
|
|
|
|
.cover-page { display: flex; align-items: center; justify-content: center; padding: 0; }
|
|
|
|
|
.cover-stickers { position: absolute; inset: 0; }
|
|
|
|
|
.cover-sticker {
|
|
|
|
|
position: absolute; object-fit: cover; border: 4px solid #fff; border-radius: 6px;
|
|
|
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.25);
|
|
|
|
|
}
|
|
|
|
|
.cover-title-wrap { position: relative; z-index: 5; text-align: center; padding: 0 12mm; }
|
|
|
|
|
.cover-title {
|
|
|
|
|
display: inline-block; font-size: 52px; background: rgba(255,253,247,0.94);
|
|
|
|
|
padding: 6mm 10mm; border-radius: 8px; box-shadow: 0 4px 14px rgba(0,0,0,0.2);
|
|
|
|
|
transform: rotate(-2deg); color: #6b4f8c; max-width: 160mm;
|
|
|
|
|
}
|
2026-09-07 12:21:53 +02:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-09-07 13:00:59 +02:00
|
|
|
${coverPage}
|
2026-09-07 12:21:53 +02:00
|
|
|
${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section>'}
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function generateBookPdf() {
|
|
|
|
|
const friends = storage.getAllFriendsMeta();
|
2026-09-07 13:00:59 +02:00
|
|
|
const settings = storage.readSettings();
|
|
|
|
|
const html = buildFullHtml(friends, settings.bookTitle);
|
2026-09-07 12:21:53 +02:00
|
|
|
|
|
|
|
|
const browser = await puppeteer.launch({
|
|
|
|
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
|
|
|
|
headless: true,
|
|
|
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const page = await browser.newPage();
|
|
|
|
|
await page.setContent(html, { waitUntil: 'networkidle0' });
|
|
|
|
|
const buffer = await page.pdf({ format: 'A4', printBackground: true });
|
|
|
|
|
return buffer;
|
|
|
|
|
} finally {
|
|
|
|
|
await browser.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { generateBookPdf, buildFullHtml };
|