2026-05-03 17:29:10 +02:00
|
|
|
|
import puppeteer from 'puppeteer';
|
2026-05-03 19:21:36 +02:00
|
|
|
|
import { readFile, writeFile, stat, rename, unlink, access } from 'fs/promises';
|
2026-05-03 17:29:10 +02:00
|
|
|
|
import { resolve, dirname } from 'path';
|
|
|
|
|
|
import { fileURLToPath } from 'url';
|
2026-05-03 19:21:36 +02:00
|
|
|
|
import { execFile } from 'child_process';
|
|
|
|
|
|
import { promisify } from 'util';
|
2026-05-03 17:29:10 +02:00
|
|
|
|
import nunjucks from 'nunjucks';
|
2026-05-03 18:44:19 +02:00
|
|
|
|
import matter from 'gray-matter';
|
2026-05-03 17:29:10 +02:00
|
|
|
|
|
2026-05-03 19:21:36 +02:00
|
|
|
|
const execFileAsync = promisify(execFile);
|
2026-05-03 17:29:10 +02:00
|
|
|
|
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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:21:36 +02:00
|
|
|
|
// 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',
|
2026-05-12 21:49:38 +02:00
|
|
|
|
'-dCompatibilityLevel=1.3',
|
2026-05-03 19:21:36 +02:00
|
|
|
|
'-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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 17:29:10 +02:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-03 18:44:19 +02:00
|
|
|
|
// Load author / title from front matter
|
|
|
|
|
|
const fmRaw = await readFile(resolve(root, 'content', '00-front-matter.md'), 'utf-8');
|
|
|
|
|
|
const { data: fm } = matter(fmRaw);
|
2026-05-03 19:21:36 +02:00
|
|
|
|
const title = fm.title || 'Das Kaleidoskop der Schlummerwelten';
|
|
|
|
|
|
const author = fm.author || '';
|
2026-05-03 18:44:19 +02:00
|
|
|
|
|
2026-05-03 17:29:10 +02:00
|
|
|
|
// 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,
|
2026-05-03 19:21:36 +02:00
|
|
|
|
author,
|
|
|
|
|
|
title,
|
2026-05-03 18:44:19 +02:00
|
|
|
|
subtitle: fm.subtitle || '',
|
2026-05-03 17:29:10 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-03 19:21:36 +02:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-05-03 17:29:10 +02:00
|
|
|
|
await writeFile(htmlOut, html, 'utf-8');
|
|
|
|
|
|
|
2026-05-03 19:21:36 +02:00
|
|
|
|
// Generate raw PDF with Puppeteer
|
2026-05-03 17:29:10 +02:00
|
|
|
|
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({
|
2026-05-03 19:21:36 +02:00
|
|
|
|
path: rawPath,
|
2026-05-03 17:29:10 +02:00
|
|
|
|
width: `${totalWidth}in`,
|
|
|
|
|
|
height: `${totalHeight}in`,
|
|
|
|
|
|
printBackground: true,
|
|
|
|
|
|
margin: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await browser.close();
|
2026-05-03 19:21:36 +02:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-05-03 17:29:10 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
generate().catch(err => { console.error(err); process.exit(1); });
|