Files
valknar 0e2ad13aaf fix(cover): round page count to even before computing spine width
Amazon rounds odd page counts up to the next even number before
calculating spine thickness. 99 pages → 100 effective pages gives
0.2347 in (5.96 mm) instead of 0.2324 in (5.90 mm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:25:58 +02:00

180 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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); });