42b31bb661
PDF 1.4 preserves transparency objects, which KDP's preflight rejects. Downgrading to 1.3 forces Ghostscript to composite all rgba overlays, gradient alphas and image opacities into solid pixels during the write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
6.2 KiB
JavaScript
179 lines
6.2 KiB
JavaScript
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');
|
||
}
|
||
|
||
// Compute dimensions
|
||
const spineWidth = pageCount * 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); });
|