Add Ghostscript post-processing to cover.js (CMYK + font embed + metadata)

Puppeteer writes cover-raw.pdf, Ghostscript converts RGB→CMYK via
-sColorConversionStrategy=CMYK -dProcessColorModel=/DeviceCMYK,
fully embeds fonts (-dPDFSETTINGS=/prepress), and injects title/author
metadata via UTF-16BE pdfmarks. Temp files are cleaned up on success.
Mirrors the same GS pipeline already used in pdf.js for the interior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:21:36 +02:00
parent 0ced340519
commit 07f1b9d3a6
+79 -8
View File
@@ -1,10 +1,13 @@
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { readFile, writeFile, access } from 'fs/promises'; import { readFile, writeFile, stat, rename, unlink, access } from 'fs/promises';
import { resolve, dirname } from 'path'; import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { execFile } from 'child_process';
import { promisify } from 'util';
import nunjucks from 'nunjucks'; import nunjucks from 'nunjucks';
import matter from 'gray-matter'; import matter from 'gray-matter';
const execFileAsync = promisify(execFile);
const __dir = dirname(fileURLToPath(import.meta.url)); const __dir = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dir, '..'); const root = resolve(__dir, '..');
@@ -21,6 +24,58 @@ async function fileExists(p) {
try { await access(p); return true; } catch { return false; } try { await access(p); return true; } catch { return false; }
} }
// Encode a JS string as UTF-16BE hex for PostScript pdfmarks (handles umlauts etc.)
function toUtf16BeHex(str) {
const hex = [];
for (let i = 0; i < str.length; i++) {
const cp = str.charCodeAt(i);
hex.push(((cp >> 8) & 0xff).toString(16).padStart(2, '0'));
hex.push((cp & 0xff).toString(16).padStart(2, '0'));
}
return 'FEFF' + hex.join('').toUpperCase();
}
function buildPdfmarks({ title, author }) {
return [
'[ /Title <' + toUtf16BeHex(title) + '>',
' /Author <' + toUtf16BeHex(author) + '>',
' /Creator (Puppeteer + Ghostscript)',
' /DOCINFO pdfmark',
].join('\n');
}
async function runGhostscript({ rawPath, outputPath, marksPath, title, author, totalWidth, totalHeight }) {
await writeFile(marksPath, buildPdfmarks({ title, author }), 'utf-8');
const args = [
'-dBATCH', '-dNOPAUSE', '-dQUIET',
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.4',
'-dPDFSETTINGS=/prepress',
'-dEmbedAllFonts=true',
'-dSubsetFonts=false',
'-dDownsampleColorImages=false',
'-dDownsampleGrayImages=false',
'-dDownsampleMonoImages=false',
// RGB → CMYK conversion
'-sColorConversionStrategy=CMYK',
'-dProcessColorModel=/DeviceCMYK',
`-sOutputFile=${outputPath}`,
marksPath,
rawPath,
];
try {
await execFileAsync('gs', args);
} catch (err) {
console.error('Ghostscript failed:', err.message);
await rename(rawPath, outputPath);
console.warn('Falling back to raw Puppeteer PDF (RGB, no GS post-processing).');
return false;
}
return true;
}
async function generate() { async function generate() {
// Read page count produced by build.js // Read page count produced by build.js
const metaPath = resolve(root, 'output', 'book-meta.json'); const metaPath = resolve(root, 'output', 'book-meta.json');
@@ -52,6 +107,8 @@ async function generate() {
// Load author / title from front matter // Load author / title from front matter
const fmRaw = await readFile(resolve(root, 'content', '00-front-matter.md'), 'utf-8'); const fmRaw = await readFile(resolve(root, 'content', '00-front-matter.md'), 'utf-8');
const { data: fm } = matter(fmRaw); const { data: fm } = matter(fmRaw);
const title = fm.title || 'Das Kaleidoskop der Schlummerwelten';
const author = fm.author || '';
// Render HTML from template // Render HTML from template
const env = nunjucks.configure(resolve(root, 'templates'), { autoescape: false }); const env = nunjucks.configure(resolve(root, 'templates'), { autoescape: false });
@@ -68,15 +125,19 @@ async function generate() {
hasSpineText: pageCount >= MIN_SPINE_TEXT, hasSpineText: pageCount >= MIN_SPINE_TEXT,
frontImage, frontImage,
backImage, backImage,
author: fm.author || '', author,
title: fm.title || 'Das Kaleidoskop der Schlummerwelten', title,
subtitle: fm.subtitle || '', subtitle: fm.subtitle || '',
}); });
const htmlOut = resolve(root, 'output', 'cover.html'); const htmlOut = resolve(root, 'output', 'cover.html');
const rawPath = resolve(root, 'output', 'cover-raw.pdf');
const marksPath = resolve(root, 'output', 'cover-pdfmarks.ps');
const pdfOut = resolve(root, 'output', 'cover.pdf');
await writeFile(htmlOut, html, 'utf-8'); await writeFile(htmlOut, html, 'utf-8');
// Generate PDF // Generate raw PDF with Puppeteer
const systemChromium = '/usr/bin/chromium'; const systemChromium = '/usr/bin/chromium';
const useSystem = await fileExists(systemChromium); const useSystem = await fileExists(systemChromium);
console.log(`Launching Puppeteer (${useSystem ? 'system Chromium' : 'bundled Chrome'})…`); console.log(`Launching Puppeteer (${useSystem ? 'system Chromium' : 'bundled Chrome'})…`);
@@ -89,9 +150,8 @@ async function generate() {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(`file://${htmlOut}`, { waitUntil: 'networkidle0' }); await page.goto(`file://${htmlOut}`, { waitUntil: 'networkidle0' });
const pdfOut = resolve(root, 'output', 'cover.pdf');
await page.pdf({ await page.pdf({
path: pdfOut, path: rawPath,
width: `${totalWidth}in`, width: `${totalWidth}in`,
height: `${totalHeight}in`, height: `${totalHeight}in`,
printBackground: true, printBackground: true,
@@ -99,9 +159,20 @@ async function generate() {
}); });
await browser.close(); await browser.close();
const rawSize = (await stat(rawPath)).size;
console.log(`Raw PDF: ${(rawSize / 1_048_576).toFixed(1)} MB`);
console.log('Running Ghostscript (CMYK conversion + font embedding + metadata)…');
const gsOk = await runGhostscript({ rawPath, outputPath: pdfOut, marksPath, title, author, totalWidth, totalHeight });
if (gsOk) {
const finalSize = (await stat(pdfOut)).size;
console.log(`Final PDF: ${(finalSize / 1_048_576).toFixed(1)} MB (CMYK)`);
await unlink(rawPath).catch(() => {});
await unlink(marksPath).catch(() => {});
console.log('Cover PDF written to output/cover.pdf'); console.log('Cover PDF written to output/cover.pdf');
console.log('\nNote: KDP recommends CMYK for covers. This PDF is RGB.'); }
console.log('Convert with Adobe Acrobat or Ghostscript before upload if needed.');
} }
generate().catch(err => { console.error(err); process.exit(1); }); generate().catch(err => { console.error(err); process.exit(1); });