Fix PDF-Export
This commit is contained in:
@@ -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">🎵 Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}<br/><small>(im Web-Freundesbuch anhö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();
|
||||
|
||||
Reference in New Issue
Block a user