Add KDP cover PDF generator

scripts/cover.js computes the exact cover canvas dimensions from the
page count in book-meta.json (written by build.js), using the KDP
Premium Color spine formula (0.002347 in/page), and renders a
Nunjucks template to a single PDF containing back cover, spine, and
front cover with bleed (0.125 in) and safe-zone overlay guides.

- `pnpm cover`  — generate output/cover.pdf
- `pnpm all`    — build interior + both PDFs in one command
- Cover artwork slots: images/cover/front.png, images/cover/back.png

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 17:29:10 +02:00
parent ae82edac2c
commit 1037b84eaa
6 changed files with 552 additions and 3 deletions
+8 -2
View File
@@ -64,8 +64,14 @@ async function build() {
const outPath = join(root, 'output', 'book.html');
await writeFile(outPath, html, 'utf-8');
const pageCount = stories.reduce((acc, s) => acc + s.scenes.length * 2, 0) + 5;
console.log(`Built output/book.html — ${stories.length} stories, ~${pageCount} pages`);
// title page + copyright + TOC + (4 scenes × 2 pages per story) + finale
const pageCount = 3 + stories.reduce((acc, s) => acc + s.scenes.length * 2, 0) + 1;
await writeFile(
join(root, 'output', 'book-meta.json'),
JSON.stringify({ pageCount, storyCount: stories.length, builtAt: new Date().toISOString() }, null, 2),
'utf-8'
);
console.log(`Built output/book.html — ${stories.length} stories, ${pageCount} pages`);
}
build().catch(err => { console.error(err); process.exit(1); });
+99
View File
@@ -0,0 +1,99 @@
import puppeteer from 'puppeteer';
import { readFile, writeFile, access } from 'fs/promises';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import nunjucks from 'nunjucks';
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; }
}
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;
// 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,
});
const htmlOut = resolve(root, 'output', 'cover.html');
await writeFile(htmlOut, html, 'utf-8');
// Generate PDF
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' });
const pdfOut = resolve(root, 'output', 'cover.pdf');
await page.pdf({
path: pdfOut,
width: `${totalWidth}in`,
height: `${totalHeight}in`,
printBackground: true,
margin: { top: 0, bottom: 0, left: 0, right: 0 },
});
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.');
}
generate().catch(err => { console.error(err); process.exit(1); });