Fix PDF-Export

This commit is contained in:
2026-09-07 13:32:35 +02:00
parent e0326285d3
commit a426d5d779
4 changed files with 516 additions and 31 deletions

View File

@@ -1,16 +1,30 @@
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');
const sharp = require('sharp');
const storage = require('./utils/storage');
function fileToDataUri(fullPath) {
// Bilder werden vor dem Einbetten verkleinert/komprimiert (JPEG). Das hält die
// generierte HTML-Seite klein genug, damit Chromium sie zuverlässig und schnell
// rendert (große Original-Fotos als Base64 können sonst zu Timeouts/Abstürzen führen).
async function fileToDataUri(fullPath, maxWidth = 900) {
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')}`;
const resized = await sharp(fullPath)
.rotate() // EXIF-Ausrichtung berücksichtigen
.resize({ width: maxWidth, withoutEnlargement: true })
.jpeg({ quality: 74 })
.toBuffer();
return `data:image/jpeg;base64,${resized.toString('base64')}`;
} catch (e) {
return null;
// Fallback: Originaldatei unverändert einbetten (z. B. bei nicht unterstützten Formaten)
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 (e2) {
return null;
}
}
}
@@ -44,7 +58,7 @@ function collectStickerImages(friends) {
return imgs;
}
function buildCoverPageHtml(title, friends) {
async function buildCoverPageHtml(title, friends) {
let files = collectStickerImages(friends);
if (files.length > COVER_MAX_STICKERS) {
const step = files.length / COVER_MAX_STICKERS;
@@ -54,9 +68,9 @@ function buildCoverPageHtml(title, friends) {
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);
const stickerHtmlList = await Promise.all(
files.map(async (filePath, i) => {
const uri = await fileToDataUri(filePath, 300); // Sticker sind klein -> niedrige Auflösung reicht
if (!uri) return '';
const cellX = i % cols;
const cellY = Math.floor(i / cols);
@@ -70,11 +84,11 @@ function buildCoverPageHtml(title, friends) {
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-stickers">${stickerHtmlList.join('')}</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>
@@ -82,9 +96,11 @@ function buildCoverPageHtml(title, friends) {
</section>`;
}
function buildFriendPageHtml(friend, folder) {
async function buildFriendPageHtml(friend, folder) {
const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads');
const profileUri = friend.profileImage ? fileToDataUri(path.join(uploadsDir, friend.profileImage)) : null;
const profileUri = friend.profileImage
? await fileToDataUri(path.join(uploadsDir, friend.profileImage), 500)
: null;
const steckbriefRows = Object.entries(friend.steckbrief || {})
.filter(([, v]) => v && String(v).trim())
@@ -92,8 +108,8 @@ function buildFriendPageHtml(friend, folder) {
.join('');
const blocks = [...(friend.blocks || [])].sort((a, b) => a.order - b.order);
const blockHtml = blocks
.map((b, i) => {
const blockHtmlList = await Promise.all(
blocks.map(async (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)">
@@ -102,7 +118,7 @@ function buildFriendPageHtml(friend, folder) {
</div>`;
}
if (b.type === 'image') {
const uri = fileToDataUri(path.join(uploadsDir, b.filename));
const uri = await fileToDataUri(path.join(uploadsDir, b.filename), 700);
if (!uri) return '';
return `<div class="pdf-block image-block polaroid" style="transform: rotate(${rot}deg)">
<img src="${uri}" />
@@ -110,7 +126,7 @@ function buildFriendPageHtml(friend, folder) {
</div>`;
}
if (b.type === 'audio') {
const coverUri = b.coverFilename ? fileToDataUri(path.join(uploadsDir, b.coverFilename)) : null;
const coverUri = b.coverFilename ? await fileToDataUri(path.join(uploadsDir, b.coverFilename), 500) : null;
return `<div class="pdf-block audio-block" style="transform: rotate(${rot * 0.5}deg)">
${coverUri ? `<img class="audio-cover" src="${coverUri}" />` : ''}
<div class="audio-note">&#127925; Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}<br/><small>(im Web-Freundesbuch anh&ouml;rbar)</small></div>
@@ -118,7 +134,8 @@ function buildFriendPageHtml(friend, folder) {
}
return '';
})
.join('\n');
);
const blockHtml = blockHtmlList.join('\n');
return `
<section class="page">
@@ -137,11 +154,10 @@ function buildFriendPageHtml(friend, folder) {
</section>`;
}
function buildFullHtml(friends, bookTitle) {
const coverPage = buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends);
const pages = friends
.map((f) => buildFriendPageHtml(f, f.folderName))
.join('\n');
async function buildFullHtml(friends, bookTitle) {
const coverPage = await buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends);
const friendPages = await Promise.all(friends.map((f) => buildFriendPageHtml(f, f.folderName)));
const pages = friendPages.join('\n');
return `<!DOCTYPE html>
<html lang="de">
@@ -227,17 +243,25 @@ ${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section
async function generateBookPdf() {
const friends = storage.getAllFriendsMeta();
const settings = storage.readSettings();
const html = buildFullHtml(friends, settings.bookTitle);
const html = await buildFullHtml(friends, settings.bookTitle);
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // wichtig in Docker: /dev/shm ist oft zu klein und lässt Chromium sonst hängen
'--disable-gpu',
],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const buffer = await page.pdf({ format: 'A4', printBackground: true });
// "domcontentloaded" reicht hier völlig: die Seite ist vollständig selbst enthalten
// (Bilder als Base64 eingebettet, keine externen Ressourcen) -> "networkidle0" kann
// in Docker ohne echten Netzwerkverkehr unnötig lange bzw. nie sauber auflösen.
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 60000 });
const buffer = await page.pdf({ format: 'A4', printBackground: true, timeout: 60000 });
return buffer;
} finally {
await browser.close();