From 07f1b9d3a62d0c92b1929bb4139d2c078ddc135c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 3 May 2026 19:21:36 +0200 Subject: [PATCH] Add Ghostscript post-processing to cover.js (CMYK + font embed + metadata) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/cover.js | 91 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/scripts/cover.js b/scripts/cover.js index 0e39c46..054f5ae 100644 --- a/scripts/cover.js +++ b/scripts/cover.js @@ -1,10 +1,13 @@ 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 { fileURLToPath } from 'url'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import nunjucks from 'nunjucks'; import matter from 'gray-matter'; +const execFileAsync = promisify(execFile); const __dir = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dir, '..'); @@ -21,6 +24,58 @@ async function fileExists(p) { 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() { // Read page count produced by build.js const metaPath = resolve(root, 'output', 'book-meta.json'); @@ -52,6 +107,8 @@ async function generate() { // Load author / title from front matter const fmRaw = await readFile(resolve(root, 'content', '00-front-matter.md'), 'utf-8'); const { data: fm } = matter(fmRaw); + const title = fm.title || 'Das Kaleidoskop der Schlummerwelten'; + const author = fm.author || ''; // Render HTML from template const env = nunjucks.configure(resolve(root, 'templates'), { autoescape: false }); @@ -68,15 +125,19 @@ async function generate() { hasSpineText: pageCount >= MIN_SPINE_TEXT, frontImage, backImage, - author: fm.author || '', - title: fm.title || 'Das Kaleidoskop der Schlummerwelten', + author, + title, 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'); - // Generate PDF + // Generate raw PDF with Puppeteer const systemChromium = '/usr/bin/chromium'; const useSystem = await fileExists(systemChromium); console.log(`Launching Puppeteer (${useSystem ? 'system Chromium' : 'bundled Chrome'})…`); @@ -89,9 +150,8 @@ async function generate() { const page = await browser.newPage(); await page.goto(`file://${htmlOut}`, { waitUntil: 'networkidle0' }); - const pdfOut = resolve(root, 'output', 'cover.pdf'); await page.pdf({ - path: pdfOut, + path: rawPath, width: `${totalWidth}in`, height: `${totalHeight}in`, printBackground: true, @@ -99,9 +159,20 @@ async function generate() { }); await browser.close(); - 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.'); + + 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'); + } } generate().catch(err => { console.error(err); process.exit(1); });