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

@@ -31,7 +31,9 @@ Admin-Passwort für dich, und pro Freund ein frei wählbares Freigabe-Passwort z
aus allen Profilbildern und Bild-Blöcken der Freunde. aus allen Profilbildern und Bild-Blöcken der Freunde.
- **Eigene Reihenfolge**: Im Admin-Bereich lässt sich die Reihenfolge der Freunde per Pfeil-Buttons - **Eigene Reihenfolge**: Im Admin-Bereich lässt sich die Reihenfolge der Freunde per Pfeil-Buttons
festlegen sie bestimmt die Seitenfolge im Freundesbuch (nach dem Deckblatt) sowie im PDF. festlegen sie bestimmt die Seitenfolge im Freundesbuch (nach dem Deckblatt) sowie im PDF.
- **PDF-Export** des gesamten Freundesbuchs inkl. Deckblatt (serverseitig über Puppeteer/Chromium gerendert). - **PDF-Export** des gesamten Freundesbuchs inkl. Deckblatt (serverseitig über Puppeteer/Chromium
gerendert; Fotos werden dabei automatisch verkleinert/komprimiert, damit auch viele/große
Bilder zuverlässig und schnell zu einem PDF werden).
## Setup ## Setup

View File

@@ -13,7 +13,8 @@
"express": "^4.19.2", "express": "^4.19.2",
"multer": "^2.0.0", "multer": "^2.0.0",
"node-id3": "^0.2.6", "node-id3": "^0.2.6",
"puppeteer": "^22.15.0" "puppeteer": "^22.15.0",
"sharp": "^0.33.5"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -39,6 +40,377 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@puppeteer/browsers": { "node_modules/@puppeteer/browsers": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz",
@@ -476,6 +848,19 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -494,6 +879,16 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -650,6 +1045,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/devtools-protocol": { "node_modules/devtools-protocol": {
"version": "0.0.1312386", "version": "0.0.1312386",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz",
@@ -1946,6 +2350,45 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -2018,6 +2461,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/smart-buffer": { "node_modules/smart-buffer": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",

View File

@@ -13,6 +13,7 @@
"express": "^4.19.2", "express": "^4.19.2",
"multer": "^2.0.0", "multer": "^2.0.0",
"node-id3": "^0.2.6", "node-id3": "^0.2.6",
"puppeteer": "^22.15.0" "puppeteer": "^22.15.0",
"sharp": "^0.33.5"
} }
} }

View File

@@ -1,17 +1,31 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const sharp = require('sharp');
const storage = require('./utils/storage'); 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 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) {
// Fallback: Originaldatei unverändert einbetten (z. B. bei nicht unterstützten Formaten)
try { try {
const buf = fs.readFileSync(fullPath); const buf = fs.readFileSync(fullPath);
const ext = path.extname(fullPath).toLowerCase().replace('.', ''); const ext = path.extname(fullPath).toLowerCase().replace('.', '');
const mime = { jpg: 'jpeg', jpeg: 'jpeg', png: 'png', gif: 'gif', webp: 'webp' }[ext] || 'jpeg'; const mime = { jpg: 'jpeg', jpeg: 'jpeg', png: 'png', gif: 'gif', webp: 'webp' }[ext] || 'jpeg';
return `data:image/${mime};base64,${buf.toString('base64')}`; return `data:image/${mime};base64,${buf.toString('base64')}`;
} catch (e) { } catch (e2) {
return null; return null;
} }
}
} }
function esc(str) { function esc(str) {
@@ -44,7 +58,7 @@ function collectStickerImages(friends) {
return imgs; return imgs;
} }
function buildCoverPageHtml(title, friends) { async function buildCoverPageHtml(title, friends) {
let files = collectStickerImages(friends); let files = collectStickerImages(friends);
if (files.length > COVER_MAX_STICKERS) { if (files.length > COVER_MAX_STICKERS) {
const step = 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 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 rows = Math.max(1, Math.ceil(files.length / cols));
const stickers = files const stickerHtmlList = await Promise.all(
.map((filePath, i) => { files.map(async (filePath, i) => {
const uri = fileToDataUri(filePath); const uri = await fileToDataUri(filePath, 300); // Sticker sind klein -> niedrige Auflösung reicht
if (!uri) return ''; if (!uri) return '';
const cellX = i % cols; const cellX = i % cols;
const cellY = Math.floor(i / cols); const cellY = Math.floor(i / cols);
@@ -70,11 +84,11 @@ function buildCoverPageHtml(title, friends) {
const sizeMm = Math.round(28 + rs * 20); 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}" />`; return `<img class="cover-sticker" style="left:${leftPct}%; top:${topPct}%; width:${sizeMm}mm; height:${sizeMm}mm; transform: rotate(${rot}deg);" src="${uri}" />`;
}) })
.join(''); );
return ` return `
<section class="page cover-page"> <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="cover-title-wrap">
<div class="tape" style="position:static; margin: 0 auto 6mm; transform: rotate(-4deg);"></div> <div class="tape" style="position:static; margin: 0 auto 6mm; transform: rotate(-4deg);"></div>
<h1 class="cover-title">${esc(title)}</h1> <h1 class="cover-title">${esc(title)}</h1>
@@ -82,9 +96,11 @@ function buildCoverPageHtml(title, friends) {
</section>`; </section>`;
} }
function buildFriendPageHtml(friend, folder) { async function buildFriendPageHtml(friend, folder) {
const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads'); 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 || {}) const steckbriefRows = Object.entries(friend.steckbrief || {})
.filter(([, v]) => v && String(v).trim()) .filter(([, v]) => v && String(v).trim())
@@ -92,8 +108,8 @@ function buildFriendPageHtml(friend, folder) {
.join(''); .join('');
const blocks = [...(friend.blocks || [])].sort((a, b) => a.order - b.order); const blocks = [...(friend.blocks || [])].sort((a, b) => a.order - b.order);
const blockHtml = blocks const blockHtmlList = await Promise.all(
.map((b, i) => { blocks.map(async (b, i) => {
const rot = ROTATIONS[i % ROTATIONS.length]; const rot = ROTATIONS[i % ROTATIONS.length];
if (b.type === 'text') { if (b.type === 'text') {
return `<div class="pdf-block text-block" style="transform: rotate(${rot * 0.4}deg)"> return `<div class="pdf-block text-block" style="transform: rotate(${rot * 0.4}deg)">
@@ -102,7 +118,7 @@ function buildFriendPageHtml(friend, folder) {
</div>`; </div>`;
} }
if (b.type === 'image') { 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 ''; if (!uri) return '';
return `<div class="pdf-block image-block polaroid" style="transform: rotate(${rot}deg)"> return `<div class="pdf-block image-block polaroid" style="transform: rotate(${rot}deg)">
<img src="${uri}" /> <img src="${uri}" />
@@ -110,7 +126,7 @@ function buildFriendPageHtml(friend, folder) {
</div>`; </div>`;
} }
if (b.type === 'audio') { 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)"> return `<div class="pdf-block audio-block" style="transform: rotate(${rot * 0.5}deg)">
${coverUri ? `<img class="audio-cover" src="${coverUri}" />` : ''} ${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> <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 ''; return '';
}) })
.join('\n'); );
const blockHtml = blockHtmlList.join('\n');
return ` return `
<section class="page"> <section class="page">
@@ -137,11 +154,10 @@ function buildFriendPageHtml(friend, folder) {
</section>`; </section>`;
} }
function buildFullHtml(friends, bookTitle) { async function buildFullHtml(friends, bookTitle) {
const coverPage = buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends); const coverPage = await buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends);
const pages = friends const friendPages = await Promise.all(friends.map((f) => buildFriendPageHtml(f, f.folderName)));
.map((f) => buildFriendPageHtml(f, f.folderName)) const pages = friendPages.join('\n');
.join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="de"> <html lang="de">
@@ -227,17 +243,25 @@ ${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section
async function generateBookPdf() { async function generateBookPdf() {
const friends = storage.getAllFriendsMeta(); const friends = storage.getAllFriendsMeta();
const settings = storage.readSettings(); const settings = storage.readSettings();
const html = buildFullHtml(friends, settings.bookTitle); const html = await buildFullHtml(friends, settings.bookTitle);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
headless: true, 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 { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' }); // "domcontentloaded" reicht hier völlig: die Seite ist vollständig selbst enthalten
const buffer = await page.pdf({ format: 'A4', printBackground: true }); // (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; return buffer;
} finally { } finally {
await browser.close(); await browser.close();