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
+6 -1
View File
@@ -1,5 +1,10 @@
.claude/ .claude/
node_modules/ node_modules/
output/book.html output/book.html
# Keep the PDF in git if you want to track versions, otherwise add: output/cover.html
output/book-meta.json
# Interior PDF is large (~100 MB with placeholder pages) — excluded from git.
# Re-add images and re-run `pnpm all` to regenerate.
output/kaleidoskop.pdf output/kaleidoskop.pdf
# Cover PDF is small (~140 KB) — tracked in git.
# output/cover.pdf
BIN
View File
Binary file not shown.
+2
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"build": "node scripts/build.js", "build": "node scripts/build.js",
"pdf": "node scripts/pdf.js", "pdf": "node scripts/pdf.js",
"cover": "node scripts/cover.js",
"book": "pnpm build && pnpm pdf", "book": "pnpm build && pnpm pdf",
"all": "pnpm build && pnpm pdf && pnpm cover",
"watch": "node --watch scripts/build.js" "watch": "node --watch scripts/build.js"
}, },
"pnpm": { "pnpm": {
+8 -2
View File
@@ -64,8 +64,14 @@ async function build() {
const outPath = join(root, 'output', 'book.html'); const outPath = join(root, 'output', 'book.html');
await writeFile(outPath, html, 'utf-8'); await writeFile(outPath, html, 'utf-8');
const pageCount = stories.reduce((acc, s) => acc + s.scenes.length * 2, 0) + 5; // title page + copyright + TOC + (4 scenes × 2 pages per story) + finale
console.log(`Built output/book.html — ${stories.length} stories, ~${pageCount} pages`); 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); }); 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); });
+437
View File
@@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Cover Das Kaleidoskop der Schlummerwelten</title>
<style>
:root {
--bleed: {{ bleed }}in;
--trim-w: {{ trimW }}in;
--trim-h: {{ trimH }}in;
--spine-w: {{ spineWidth }}in;
--total-w: {{ totalWidth }}in;
--total-h: {{ totalHeight }}in;
--safe: {{ safe }}in;
--spine-safe: {{ spineSafe }}in;
/* Derived positions (left edge of each zone) */
--back-left: 0in;
--spine-left: calc(var(--bleed) + var(--trim-w));
--front-left: calc(var(--bleed) + var(--trim-w) + var(--spine-w));
/* Design tokens */
--color-bg: #080820;
--color-mid: #111138;
--color-gold: #c8a84b;
--color-gold2: #f0d080;
--color-text: #e8e4d8;
--color-muted: rgba(200,168,75,0.45);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: var(--total-w);
height: var(--total-h);
background: var(--color-bg);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
overflow: hidden;
}
/* ── Canvas ─────────────────────────────────────────────── */
.canvas {
position: relative;
width: var(--total-w);
height: var(--total-h);
background: var(--color-bg);
}
/* Star-field background (pure CSS, no images required) */
.canvas::before {
content: '';
position: absolute;
inset: 0;
background-image:
radial-gradient(1px 1px at 12% 18%, rgba(255,255,255,0.7) 0%, transparent 100%),
radial-gradient(1px 1px at 28% 42%, rgba(255,255,255,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 44% 11%, rgba(255,255,255,0.6) 0%, transparent 100%),
radial-gradient(1px 1px at 60% 73%, rgba(255,255,255,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 76% 29%, rgba(255,255,255,0.7) 0%, transparent 100%),
radial-gradient(1px 1px at 88% 55%, rgba(255,255,255,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 7% 66%, rgba(255,255,255,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 51% 88%, rgba(255,255,255,0.6) 0%, transparent 100%),
radial-gradient(1px 1px at 33% 77%, rgba(255,255,255,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 93% 14%, rgba(255,255,255,0.5) 0%, transparent 100%),
radial-gradient(2px 2px at 20% 5%, rgba(200,168,75,0.5) 0%, transparent 100%),
radial-gradient(2px 2px at 68% 90%, rgba(200,168,75,0.4) 0%, transparent 100%),
radial-gradient(2px 2px at 85% 38%, rgba(200,168,75,0.5) 0%, transparent 100%);
}
/* ── Zones ──────────────────────────────────────────────── */
.zone {
position: absolute;
top: 0;
height: var(--total-h);
}
.zone--back {
left: var(--back-left);
width: calc(var(--bleed) + var(--trim-w));
}
.zone--spine {
left: var(--spine-left);
width: var(--spine-w);
background: var(--color-mid);
border-left: 0.5px solid rgba(200,168,75,0.2);
border-right: 0.5px solid rgba(200,168,75,0.2);
}
.zone--front {
left: var(--front-left);
width: calc(var(--trim-w) + var(--bleed));
}
/* ── Back cover content ─────────────────────────────────── */
.back-content {
position: absolute;
/* safe zone: bleed + safe from left, safe from right, bleed+safe from top/bottom */
top: calc(var(--bleed) + var(--safe));
left: calc(var(--bleed) + var(--safe));
right: var(--safe);
bottom: calc(var(--bleed) + var(--safe));
display: flex;
flex-direction: column;
justify-content: space-between;
}
.back-headline {
font-family: Georgia, serif;
font-style: italic;
font-size: 0.22in;
color: var(--color-gold);
letter-spacing: 0.02em;
margin-bottom: 0.12in;
}
.back-synopsis {
font-family: Georgia, serif;
font-size: 0.145in;
line-height: 1.7;
color: var(--color-text);
flex: 1;
}
.back-synopsis p { margin-bottom: 0.1in; }
.back-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.back-tagline {
font-family: Georgia, serif;
font-style: italic;
font-size: 0.115in;
color: var(--color-muted);
max-width: 60%;
line-height: 1.5;
}
/* Barcode placeholder — KDP places the barcode here if left blank */
.barcode-placeholder {
width: 1in;
height: 0.6in;
background: white;
border: 1px solid rgba(200,168,75,0.3);
display: flex;
align-items: center;
justify-content: center;
}
.barcode-placeholder span {
font-size: 0.07in;
color: #aaa;
text-align: center;
line-height: 1.4;
}
/* Optional back cover artwork */
.back-artwork {
position: absolute;
inset: 0;
object-fit: cover;
opacity: 0.35;
}
/* ── Spine content ──────────────────────────────────────── */
.spine-content {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.spine-text {
transform: rotate(90deg);
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.15in;
}
.spine-title {
font-family: Georgia, serif;
font-style: italic;
font-size: 0.13in;
color: var(--color-gold);
letter-spacing: 0.04em;
}
.spine-divider {
font-size: 0.1in;
color: var(--color-muted);
}
.spine-author {
font-family: Georgia, serif;
font-size: 0.1in;
color: var(--color-text);
letter-spacing: 0.05em;
}
.spine-too-narrow {
transform: rotate(90deg);
font-size: 0.08in;
color: rgba(255,80,80,0.8);
white-space: nowrap;
font-family: monospace;
}
/* ── Front cover content ────────────────────────────────── */
.front-artwork {
position: absolute;
/* art fills from left trim edge (skip the right bleed) */
top: 0; left: 0;
width: var(--trim-w);
height: var(--total-h);
object-fit: cover;
}
.front-content {
position: absolute;
top: calc(var(--bleed) + var(--safe));
left: var(--safe);
right: calc(var(--safe) + var(--bleed));
bottom: calc(var(--bleed) + var(--safe));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.15in;
}
.front-ornament-top {
font-size: 0.18in;
color: var(--color-gold);
letter-spacing: 0.3em;
}
.front-title {
font-family: Georgia, serif;
font-style: italic;
font-size: 0.52in;
line-height: 1.2;
color: var(--color-gold2);
text-shadow: 0 0 0.15in rgba(200,168,75,0.4), 0 2px 8px rgba(0,0,0,0.8);
}
.front-subtitle {
font-family: Georgia, serif;
font-size: 0.18in;
letter-spacing: 0.08em;
color: var(--color-text);
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
}
/* Placeholder box shown when no front artwork is provided */
.front-art-placeholder {
position: absolute;
top: var(--bleed);
left: 0;
width: var(--trim-w);
height: var(--trim-h);
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, #1a1a4a 0%, #080820 70%);
}
.front-art-placeholder span {
font-family: monospace;
font-size: 0.12in;
color: rgba(200,168,75,0.4);
border: 1px dashed rgba(200,168,75,0.2);
padding: 0.15in 0.3in;
}
.front-author {
font-family: Georgia, serif;
font-size: 0.16in;
letter-spacing: 0.1em;
color: rgba(200,168,75,0.7);
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
}
.front-ornament-bottom {
font-size: 0.14in;
color: var(--color-muted);
letter-spacing: 0.4em;
}
/* ── Safe-zone & bleed guides overlay ───────────────────── */
/*
* These dashed lines are visual guides only — they mark the trim lines
* and safe zones. Remove or comment out this section before final upload
* if you prefer a clean proof. KDP will print nothing outside the trim.
*/
.guides {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Outer bleed boundary = trim line */
.guide--trim-top { position: absolute; top: var(--bleed); left: 0; right: 0; border-top: 0.5px dashed rgba(255,80,80,0.5); }
.guide--trim-bottom { position: absolute; bottom: var(--bleed); left: 0; right: 0; border-top: 0.5px dashed rgba(255,80,80,0.5); }
.guide--trim-left { position: absolute; left: var(--bleed); top: 0; bottom: 0; border-left: 0.5px dashed rgba(255,80,80,0.5); }
.guide--trim-right { position: absolute; right: var(--bleed); top: 0; bottom: 0; border-left: 0.5px dashed rgba(255,80,80,0.5); }
/* Safe zone (bleed + safe from edge) */
.guide--safe-top { position: absolute; top: calc(var(--bleed) + var(--safe)); left: 0; right: 0; border-top: 0.5px dashed rgba(255,220,50,0.4); }
.guide--safe-bottom { position: absolute; bottom: calc(var(--bleed) + var(--safe)); left: 0; right: 0; border-top: 0.5px dashed rgba(255,220,50,0.4); }
.guide--safe-left { position: absolute; left: calc(var(--bleed) + var(--safe)); top: 0; bottom: 0; border-left: 0.5px dashed rgba(255,220,50,0.4); }
.guide--safe-right { position: absolute; right: calc(var(--bleed) + var(--safe)); top: 0; bottom: 0; border-left: 0.5px dashed rgba(255,220,50,0.4); }
/* Spine boundaries */
.guide--spine-left { position: absolute; left: var(--spine-left); top: 0; bottom: 0; border-left: 0.5px dashed rgba(100,200,255,0.5); }
.guide--spine-right { position: absolute; left: calc(var(--front-left)); top: 0; bottom: 0; border-left: 0.5px dashed rgba(100,200,255,0.5); }
/* Guide labels */
.guide-label {
position: absolute;
font-family: monospace;
font-size: 0.07in;
background: rgba(0,0,0,0.6);
padding: 1px 3px;
border-radius: 2px;
white-space: nowrap;
}
.guide-label--bleed { color: rgba(255,80,80,0.9); top: 2px; left: calc(var(--bleed) + 2px); }
.guide-label--safe { color: rgba(255,220,50,0.9); top: calc(var(--bleed) + var(--safe) + 2px); left: calc(var(--bleed) + var(--safe) + 2px); }
.guide-label--spine { color: rgba(100,200,255,0.9); top: 2px; left: calc(var(--spine-left) + 2px); }
</style>
</head>
<body>
<div class="canvas">
{# ── Back cover ─────────────────────────────────────────── #}
<div class="zone zone--back">
{% if backImage %}
<img class="back-artwork" src="{{ backImage }}" alt="Back cover artwork">
{% endif %}
<div class="back-content">
<div>
<p class="back-headline">Zwölf magische Reisen in die Welt der Träume</p>
<div class="back-synopsis">
<p>
Jede Nacht beginnt eine neue Reise und dieses Buch hat gleich zwölf davon.
Ob gläserne Wälder, schlafende Riesen oder ein Teetassen-Boot auf einem
Fluss aus Sternenlicht: Jede Geschichte führt sanft in eine neue Zauberwelt
und begleitet dein Kind liebevoll in den Schlaf.
</p>
<p>
Zwölf unabhängige Einschlafgeschichten · Traumhafte Aquarell-Illustrationen<br>
Für Kinder von 3 bis 8 Jahren · Ideal zum Vorlesen
</p>
</div>
</div>
<div class="back-bottom">
<p class="back-tagline">
„Die schönsten Welten sind nur<br>mit geschlossenen Augen zu erreichen."
</p>
{# Leave this box empty — KDP will place the barcode here automatically #}
<div class="barcode-placeholder">
<span>Barcode<br>(KDP)</span>
</div>
</div>
</div>
</div>{# end back #}
{# ── Spine ──────────────────────────────────────────────── #}
<div class="zone zone--spine">
<div class="spine-content">
{% if hasSpineText %}
<div class="spine-text">
<span class="spine-title">Das Kaleidoskop der Schlummerwelten</span>
<span class="spine-divider"></span>
<span class="spine-author">[AUTOR]</span>
</div>
{% else %}
<span class="spine-too-narrow">too narrow ({{ pageCount }}p &lt; 79)</span>
{% endif %}
</div>
</div>{# end spine #}
{# ── Front cover ─────────────────────────────────────────── #}
<div class="zone zone--front">
{% if frontImage %}
<img class="front-artwork" src="{{ frontImage }}" alt="Front cover artwork">
{% else %}
<div class="front-art-placeholder">
<span>Cover-Illustration<br>→ images/cover/front.png</span>
</div>
{% endif %}
<div class="front-content">
<p class="front-ornament-top">✦ ✦ ✦</p>
<h1 class="front-title">Das Kaleidoskop<br>der Schlummerwelten</h1>
<p class="front-subtitle">Zwölf magische Einschlafgeschichten</p>
<p class="front-author">[AUTOR]</p>
<p class="front-ornament-bottom">· · ·</p>
</div>
</div>{# end front #}
{# ── Guides overlay (trim lines, safe zones, spine) ──────── #}
{# Remove this block before final upload to KDP #}
<div class="guides">
<div class="guide--trim-top"></div>
<div class="guide--trim-bottom"></div>
<div class="guide--trim-left"></div>
<div class="guide--trim-right"></div>
<div class="guide--safe-top"></div>
<div class="guide--safe-bottom"></div>
<div class="guide--safe-left"></div>
<div class="guide--safe-right"></div>
<div class="guide--spine-left"></div>
<div class="guide--spine-right"></div>
<span class="guide-label guide-label--bleed">← Beschnitt (3,2 mm)</span>
<span class="guide-label guide-label--safe">← Sicherheitsabstand (3,2 mm)</span>
<span class="guide-label guide-label--spine">← Rücken ({{ spineWidth }} Zoll / {{ pageCount }} S.)</span>
</div>
</div>{# end canvas #}
</body>
</html>