import puppeteer from 'puppeteer'; 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, '..'); // KDP constants — Premium Color paper, 8.5×8.5 in trim const BLEED_IN = 0.125; const TRIM_W_IN = 8.5; const TRIM_H_IN = 8.5; const SPINE_PER_PAGE = 0.002347; // inches per page, Premium Color const SAFE_IN = 0.125; // safe zone inset from trim edge (front/back) const SPINE_SAFE_IN = 0.0625; // safe zone inset from spine fold (each side) const MIN_SPINE_TEXT = 79; // KDP minimum pages for spine text 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.3', '-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'); let pageCount = 100; if (await fileExists(metaPath)) { const meta = JSON.parse(await readFile(metaPath, 'utf-8')); pageCount = meta.pageCount; console.log(`Page count: ${pageCount} (from output/book-meta.json)`); } else { console.warn(`book-meta.json not found — using default page count ${pageCount}`); console.warn('Run `pnpm build` first for an accurate spine width.\n'); } // Amazon rounds odd page counts up to even before computing spine width const effectivePageCount = pageCount % 2 === 0 ? pageCount : pageCount + 1; const spineWidth = effectivePageCount * SPINE_PER_PAGE; const totalWidth = BLEED_IN + TRIM_W_IN + spineWidth + TRIM_W_IN + BLEED_IN; const totalHeight = BLEED_IN + TRIM_H_IN + BLEED_IN; console.log(`Spine width : ${spineWidth.toFixed(4)} in`); console.log(`Cover canvas: ${totalWidth.toFixed(4)} × ${totalHeight.toFixed(4)} in\n`); // Check for optional artwork files const frontImage = await fileExists(resolve(root, 'images/cover/front.png')) ? '../images/cover/front.png' : null; const backImage = await fileExists(resolve(root, 'images/cover/back.png')) ? '../images/cover/back.png' : null; // 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 }); const html = env.render('cover.html', { pageCount, spineWidth: spineWidth.toFixed(4), totalWidth: totalWidth.toFixed(4), totalHeight: totalHeight.toFixed(4), bleed: BLEED_IN, trimW: TRIM_W_IN, trimH: TRIM_H_IN, safe: SAFE_IN, spineSafe: SPINE_SAFE_IN, hasSpineText: pageCount >= MIN_SPINE_TEXT, frontImage, backImage, author, title, subtitle: fm.subtitle || '', }); 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 raw PDF with Puppeteer const systemChromium = '/usr/bin/chromium'; const useSystem = await fileExists(systemChromium); console.log(`Launching Puppeteer (${useSystem ? 'system Chromium' : 'bundled Chrome'})…`); const browser = await puppeteer.launch({ headless: true, ...(useSystem ? { executablePath: systemChromium } : {}), }); const page = await browser.newPage(); await page.goto(`file://${htmlOut}`, { waitUntil: 'networkidle0' }); await page.pdf({ path: rawPath, width: `${totalWidth}in`, height: `${totalHeight}in`, printBackground: true, margin: { top: 0, bottom: 0, left: 0, right: 0 }, }); 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'); } } generate().catch(err => { console.error(err); process.exit(1); });