1616 lines
49 KiB
TypeScript
1616 lines
49 KiB
TypeScript
#!/usr/bin/env ts-node
|
||
/**
|
||
* PDF Datasheet Generator - Industrial Engineering Documentation Style
|
||
* STYLEGUIDE.md compliant: industrial, technical, restrained
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { PDFDocument, rgb, StandardFonts, PDFFont, PDFPage, PDFImage } from 'pdf-lib';
|
||
|
||
let sharpFn: ((input?: any, options?: any) => any) | null = null;
|
||
async function getSharp(): Promise<(input?: any, options?: any) => any> {
|
||
if (sharpFn) return sharpFn;
|
||
// `sharp` is CJS but this script runs as ESM via ts-node.
|
||
// Dynamic import gives stable interop.
|
||
const mod: any = await import('sharp');
|
||
sharpFn = (mod?.default || mod) as (input?: any, options?: any) => any;
|
||
return sharpFn;
|
||
}
|
||
|
||
const CONFIG = {
|
||
productsFile: path.join(process.cwd(), 'data/processed/products.json'),
|
||
outputDir: path.join(process.cwd(), 'public/datasheets'),
|
||
chunkSize: 10,
|
||
siteUrl: 'https://klz-cables.com',
|
||
};
|
||
|
||
const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json');
|
||
const PUBLIC_DIR = path.join(process.cwd(), 'public');
|
||
|
||
type AssetMap = Record<string, string>;
|
||
|
||
function readAssetMap(): AssetMap {
|
||
try {
|
||
if (!fs.existsSync(ASSET_MAP_FILE)) return {};
|
||
return JSON.parse(fs.readFileSync(ASSET_MAP_FILE, 'utf8')) as AssetMap;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
const ASSET_MAP: AssetMap = readAssetMap();
|
||
|
||
interface ProductData {
|
||
id: number;
|
||
name: string;
|
||
shortDescriptionHtml: string;
|
||
descriptionHtml: string;
|
||
images: string[];
|
||
featuredImage: string | null;
|
||
sku: string;
|
||
slug?: string;
|
||
path?: string;
|
||
translationKey?: string;
|
||
locale?: 'en' | 'de';
|
||
categories: Array<{ name: string }>;
|
||
attributes: Array<{
|
||
name: string;
|
||
options: string[];
|
||
}>;
|
||
}
|
||
|
||
function getProductUrl(product: ProductData): string | null {
|
||
if (!product.path) return null;
|
||
return `https://klz-cables.com${product.path}`;
|
||
}
|
||
|
||
function drawKeyValueGrid(args: {
|
||
title: string;
|
||
items: Array<{ label: string; value: string }>;
|
||
newPage: () => number;
|
||
getPage: () => PDFPage;
|
||
page: PDFPage;
|
||
y: number;
|
||
margin: number;
|
||
contentWidth: number;
|
||
contentMinY: number;
|
||
font: PDFFont;
|
||
fontBold: PDFFont;
|
||
navy: ReturnType<typeof rgb>;
|
||
darkGray: ReturnType<typeof rgb>;
|
||
mediumGray: ReturnType<typeof rgb>;
|
||
lightGray?: ReturnType<typeof rgb>;
|
||
almostWhite?: ReturnType<typeof rgb>;
|
||
allowNewPage?: boolean;
|
||
boxed?: boolean;
|
||
}): number {
|
||
let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args;
|
||
const allowNewPage = args.allowNewPage ?? true;
|
||
const boxed = args.boxed ?? false;
|
||
|
||
const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294);
|
||
const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804);
|
||
|
||
// Inner layout (boxed vs. plain)
|
||
// Keep a strict spacing system for more professional datasheets.
|
||
const padX = boxed ? 16 : 0;
|
||
const padY = boxed ? 14 : 0;
|
||
const xBase = margin + padX;
|
||
const innerWidth = contentWidth - padX * 2;
|
||
const colGap = 16;
|
||
const colW = (innerWidth - colGap) / 2;
|
||
const rowH = 24;
|
||
const headerH = boxed ? 22 : 0;
|
||
|
||
// Draw a strict rectangular section container (no rounding)
|
||
if (boxed && items.length) {
|
||
const rows = Math.ceil(items.length / 2);
|
||
const boxH = padY + headerH + rows * rowH + padY;
|
||
const bottomY = y - boxH;
|
||
if (bottomY < contentMinY) {
|
||
if (!allowNewPage) return contentMinY - 1;
|
||
y = newPage();
|
||
}
|
||
|
||
page = getPage();
|
||
page.drawRectangle({
|
||
x: margin,
|
||
y: y - boxH,
|
||
width: contentWidth,
|
||
height: boxH,
|
||
borderColor: lightGray,
|
||
borderWidth: 1,
|
||
color: rgb(1, 1, 1),
|
||
});
|
||
|
||
// Header band for the title
|
||
page.drawRectangle({
|
||
x: margin,
|
||
y: y - headerH,
|
||
width: contentWidth,
|
||
height: headerH,
|
||
color: almostWhite,
|
||
});
|
||
}
|
||
|
||
const drawTitle = () => {
|
||
page = getPage();
|
||
if (boxed) {
|
||
// Align title inside the header band.
|
||
page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy });
|
||
// Divider line below header band
|
||
page.drawLine({
|
||
start: { x: margin, y: y - headerH },
|
||
end: { x: margin + contentWidth, y: y - headerH },
|
||
thickness: 0.75,
|
||
color: lightGray,
|
||
});
|
||
y -= headerH + padY;
|
||
} else {
|
||
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
|
||
y -= 16;
|
||
}
|
||
};
|
||
|
||
if (y - 22 < contentMinY) {
|
||
if (!allowNewPage) return contentMinY - 1;
|
||
y = newPage();
|
||
}
|
||
page = getPage();
|
||
drawTitle();
|
||
|
||
let rowY = y;
|
||
for (let i = 0; i < items.length; i++) {
|
||
const col = i % 2;
|
||
const x = xBase + col * (colW + colGap);
|
||
const { label, value } = items[i];
|
||
|
||
if (col === 0 && rowY - rowH < contentMinY) {
|
||
if (!allowNewPage) return contentMinY - 1;
|
||
y = newPage();
|
||
page = getPage();
|
||
drawTitle();
|
||
rowY = y;
|
||
}
|
||
|
||
page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW });
|
||
page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW });
|
||
|
||
if (col === 1) rowY -= rowH;
|
||
}
|
||
|
||
return boxed ? rowY - rowH - padY : rowY - rowH;
|
||
}
|
||
|
||
function ensureOutputDir(): void {
|
||
if (!fs.existsSync(CONFIG.outputDir)) {
|
||
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
|
||
}
|
||
}
|
||
|
||
const stripHtml = (html: string): string => {
|
||
if (!html) return '';
|
||
// IMPORTANT: Keep umlauts and common Latin-1 chars (e.g. ü/ö/ä/ß) for DE PDFs.
|
||
// pdf-lib's StandardFonts cover WinAnsi; we only normalize “problematic” typography.
|
||
let text = html.replace(/<[^>]*>/g, '').normalize('NFC');
|
||
text = text
|
||
// whitespace normalization
|
||
.replace(/[\u00A0\u202F]/g, ' ') // nbsp / narrow nbsp
|
||
// typography normalization
|
||
.replace(/[\u2013\u2014]/g, '-') // en/em dash
|
||
.replace(/[\u2018\u2019]/g, "'") // curly single quotes
|
||
.replace(/[\u201C\u201D]/g, '"') // curly double quotes
|
||
.replace(/\u2026/g, '...') // ellipsis
|
||
// symbols that can be missing in some encodings
|
||
.replace(/[\u2022]/g, '·') // bullet
|
||
// math symbols (WinAnsi can't encode these)
|
||
.replace(/[\u2264]/g, '<=') // ≤
|
||
.replace(/[\u2265]/g, '>=') // ≥
|
||
.replace(/[\u2248]/g, '~') // ≈
|
||
// electrical symbols (keep meaning; avoid encoding errors)
|
||
.replace(/[\u03A9\u2126]/g, 'Ohm') // Ω / Ω
|
||
// micro sign / greek mu (WinAnsi can't encode these reliably)
|
||
.replace(/[\u00B5\u03BC]/g, 'u'); // µ / μ
|
||
|
||
// Remove control chars, keep all printable unicode.
|
||
text = text.replace(/[\u0000-\u001F\u007F]/g, '');
|
||
return text.replace(/\s+/g, ' ').trim();
|
||
};
|
||
|
||
const getLabels = (locale: 'en' | 'de') => ({
|
||
en: {
|
||
datasheet: 'PRODUCT DATASHEET',
|
||
description: 'DESCRIPTION',
|
||
specs: 'TECHNICAL SPECIFICATIONS',
|
||
crossSection: 'CROSS-SECTION DATA',
|
||
categories: 'CATEGORIES',
|
||
sku: 'SKU',
|
||
},
|
||
de: {
|
||
datasheet: 'PRODUKTDATENBLATT',
|
||
description: 'BESCHREIBUNG',
|
||
specs: 'TECHNISCHE SPEZIFIKATIONEN',
|
||
crossSection: 'QUERSCHNITTSDATEN',
|
||
categories: 'KATEGORIEN',
|
||
sku: 'ARTIKELNUMMER',
|
||
},
|
||
})[locale];
|
||
|
||
const generateFileName = (product: ProductData, locale: 'en' | 'de'): string => {
|
||
const baseName = product.slug || product.translationKey || `product-${product.id}`;
|
||
const cleanSlug = baseName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||
return `${cleanSlug}-${locale}.pdf`;
|
||
};
|
||
|
||
function wrapText(text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] {
|
||
const words = text.split(' ');
|
||
const lines: string[] = [];
|
||
let currentLine = '';
|
||
|
||
for (const word of words) {
|
||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||
if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) {
|
||
currentLine = testLine;
|
||
} else {
|
||
if (currentLine) lines.push(currentLine);
|
||
currentLine = word;
|
||
}
|
||
}
|
||
if (currentLine) lines.push(currentLine);
|
||
return lines;
|
||
}
|
||
|
||
function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null {
|
||
if (!urlOrPath) return null;
|
||
|
||
// 1) Already public-relative.
|
||
if (urlOrPath.startsWith('/')) return urlOrPath;
|
||
|
||
// 2) Some datasets store "media/..." without leading slash.
|
||
if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`;
|
||
|
||
// 3) Asset-map can return a few different shapes; normalize them.
|
||
const mapped = ASSET_MAP[urlOrPath];
|
||
if (mapped) {
|
||
if (mapped.startsWith('/')) return mapped;
|
||
if (/^public\//i.test(mapped)) return `/${mapped.replace(/^public\//i, '')}`;
|
||
if (/^media\//i.test(mapped)) return `/${mapped}`;
|
||
return mapped;
|
||
}
|
||
|
||
// 4) Fallback (remote URL or unrecognized local path).
|
||
return urlOrPath;
|
||
}
|
||
|
||
async function fetchBytes(url: string): Promise<Uint8Array> {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
|
||
return new Uint8Array(await res.arrayBuffer());
|
||
}
|
||
|
||
async function readBytesFromPublic(localPath: string): Promise<Uint8Array> {
|
||
const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, ''));
|
||
return new Uint8Array(fs.readFileSync(abs));
|
||
}
|
||
|
||
function transformLogoSvgToPrintBlack(svg: string): string {
|
||
// Our source logo is white-on-transparent (for dark headers). For print (white page), we need dark fills.
|
||
// Keep it simple: replace fill white with KLZ navy.
|
||
return svg
|
||
.replace(/fill\s*:\s*white/gi, 'fill:#0E2A47')
|
||
.replace(/fill\s*=\s*"white"/gi, 'fill="#0E2A47"')
|
||
.replace(/fill\s*=\s*'white'/gi, "fill='#0E2A47'");
|
||
}
|
||
|
||
async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise<Uint8Array> {
|
||
// pdf-lib supports PNG/JPG. We normalize everything (webp/svg/jpg/png) to PNG to keep embedding simple.
|
||
const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
|
||
if (ext === 'png') return inputBytes;
|
||
|
||
// Special-case the logo SVG to render as dark for print.
|
||
if (ext === 'svg' && /\/media\/logo\.svg$/i.test(inputHint)) {
|
||
const svg = Buffer.from(inputBytes).toString('utf8');
|
||
inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8'));
|
||
}
|
||
|
||
const sharp = await getSharp();
|
||
// Preserve alpha where present (some product images are transparent).
|
||
return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
|
||
}
|
||
|
||
type TableColumn = {
|
||
label: string;
|
||
get: (rowIndex: number) => string;
|
||
};
|
||
|
||
function buildProductAttrIndex(product: ProductData): Record<string, ProductData['attributes'][number]> {
|
||
const idx: Record<string, ProductData['attributes'][number]> = {};
|
||
for (const a of product.attributes || []) {
|
||
idx[normalizeValue(a.name).toLowerCase()] = a;
|
||
}
|
||
return idx;
|
||
}
|
||
|
||
function getAttrCellValue(attr: ProductData['attributes'][number] | undefined, rowIndex: number, rowCount: number): string {
|
||
if (!attr) return '';
|
||
if (!attr.options || attr.options.length === 0) return '';
|
||
if (rowCount > 0 && attr.options.length === rowCount) return normalizeValue(attr.options[rowIndex]);
|
||
if (attr.options.length === 1) return normalizeValue(attr.options[0]);
|
||
// Unknown mapping: do NOT guess (this was the main source of "wrong" tables).
|
||
return '';
|
||
}
|
||
|
||
function drawTableChunked(args: {
|
||
title: string;
|
||
configRows: string[];
|
||
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
|
||
locale: 'en' | 'de';
|
||
newPage: () => number;
|
||
getPage: () => PDFPage;
|
||
page: PDFPage;
|
||
y: number;
|
||
margin: number;
|
||
contentWidth: number;
|
||
contentMinY: number;
|
||
font: PDFFont;
|
||
fontBold: PDFFont;
|
||
navy: ReturnType<typeof rgb>;
|
||
darkGray: ReturnType<typeof rgb>;
|
||
lightGray: ReturnType<typeof rgb>;
|
||
almostWhite: ReturnType<typeof rgb>;
|
||
maxDataColsPerTable: number;
|
||
}): number {
|
||
let {
|
||
title,
|
||
configRows,
|
||
columns,
|
||
newPage,
|
||
getPage,
|
||
page,
|
||
y,
|
||
margin,
|
||
contentWidth,
|
||
contentMinY,
|
||
font,
|
||
fontBold,
|
||
navy,
|
||
darkGray,
|
||
lightGray,
|
||
almostWhite,
|
||
maxDataColsPerTable,
|
||
} = args;
|
||
|
||
const headerH = 16;
|
||
const rowH = 13;
|
||
|
||
// Always include configuration as first col.
|
||
const configCol = {
|
||
key: 'configuration',
|
||
label: args.locale === 'de' ? 'Konfiguration' : 'Configuration',
|
||
get: (i: number) => normalizeValue(configRows[i] || ''),
|
||
};
|
||
|
||
const chunks: Array<typeof columns> = [];
|
||
for (let i = 0; i < columns.length; i += maxDataColsPerTable) {
|
||
chunks.push(columns.slice(i, i + maxDataColsPerTable));
|
||
}
|
||
|
||
for (let ci = 0; ci < Math.max(1, chunks.length); ci++) {
|
||
// Ensure we always draw on the current page reference.
|
||
page = getPage();
|
||
|
||
const chunkCols = chunks.length ? chunks[ci] : [];
|
||
const chunkTitle = chunks.length > 1 ? `${title} (${ci + 1}/${chunks.length})` : title;
|
||
const tableCols: TableColumn[] = [configCol, ...chunkCols];
|
||
|
||
// Width distribution (keeps configuration readable)
|
||
const configW = 0.32;
|
||
const remainingW = 1 - configW;
|
||
const perW = remainingW / Math.max(1, tableCols.length - 1);
|
||
const widths = tableCols.map((_, idx) => (idx === 0 ? configW : perW));
|
||
|
||
const ensureSpace = (needed: number) => {
|
||
if (y - needed < contentMinY) y = newPage();
|
||
page = getPage();
|
||
};
|
||
|
||
ensureSpace(18 + headerH + rowH * 2);
|
||
page.drawText(chunkTitle, {
|
||
x: margin,
|
||
y,
|
||
size: 10,
|
||
font: fontBold,
|
||
color: navy,
|
||
});
|
||
y -= 16;
|
||
|
||
// If we are too close to the footer after the title, break before drawing the header.
|
||
if (y - headerH - rowH < contentMinY) {
|
||
y = newPage();
|
||
page = getPage();
|
||
page.drawText(chunkTitle, {
|
||
x: margin,
|
||
y,
|
||
size: 10,
|
||
font: fontBold,
|
||
color: navy,
|
||
});
|
||
y -= 16;
|
||
}
|
||
|
||
const drawHeader = () => {
|
||
page = getPage();
|
||
page.drawRectangle({
|
||
x: margin,
|
||
y: y - headerH,
|
||
width: contentWidth,
|
||
height: headerH,
|
||
color: lightGray,
|
||
});
|
||
let x = margin;
|
||
for (let i = 0; i < tableCols.length; i++) {
|
||
page.drawText(tableCols[i].label, {
|
||
x: x + 6,
|
||
y: y - 11,
|
||
size: 8,
|
||
font: fontBold,
|
||
color: navy,
|
||
maxWidth: contentWidth * widths[i] - 12,
|
||
});
|
||
x += contentWidth * widths[i];
|
||
}
|
||
y -= headerH;
|
||
};
|
||
|
||
drawHeader();
|
||
|
||
for (let r = 0; r < configRows.length; r++) {
|
||
if (y - rowH < contentMinY) {
|
||
y = newPage();
|
||
page = getPage();
|
||
page.drawText(chunkTitle, {
|
||
x: margin,
|
||
y,
|
||
size: 12,
|
||
font: fontBold,
|
||
color: navy,
|
||
});
|
||
y -= 16;
|
||
drawHeader();
|
||
}
|
||
|
||
if (r % 2 === 0) {
|
||
page.drawRectangle({
|
||
x: margin,
|
||
y: y - rowH,
|
||
width: contentWidth,
|
||
height: rowH,
|
||
color: almostWhite,
|
||
});
|
||
}
|
||
|
||
let x = margin;
|
||
for (let c = 0; c < tableCols.length; c++) {
|
||
page.drawText(tableCols[c].get(r), {
|
||
x: x + 6,
|
||
y: y - 10,
|
||
size: 8,
|
||
font,
|
||
color: darkGray,
|
||
maxWidth: contentWidth * widths[c] - 12,
|
||
});
|
||
x += contentWidth * widths[c];
|
||
}
|
||
|
||
y -= rowH;
|
||
}
|
||
|
||
y -= 18;
|
||
}
|
||
|
||
return y;
|
||
}
|
||
|
||
async function loadEmbeddablePng(
|
||
src: string | null | undefined,
|
||
): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
|
||
const resolved = resolveMediaToLocalPath(src);
|
||
if (!resolved) return null;
|
||
|
||
try {
|
||
// Prefer local files for stability and speed.
|
||
if (resolved.startsWith('/')) {
|
||
const bytes = await readBytesFromPublic(resolved);
|
||
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
|
||
}
|
||
|
||
// Remote (fallback)
|
||
const bytes = await fetchBytes(resolved);
|
||
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadQrPng(data: string): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
|
||
// External QR generator (no extra dependency). This must stay resilient; if it fails, we fall back to URL text.
|
||
try {
|
||
const safe = encodeURIComponent(data);
|
||
const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
|
||
const bytes = await fetchBytes(url);
|
||
// Already PNG but normalize anyway.
|
||
return { pngBytes: await toPngBytes(bytes, url), debugLabel: url };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type SectionDrawContext = {
|
||
pdfDoc: PDFDocument;
|
||
page: PDFPage;
|
||
width: number;
|
||
height: number;
|
||
margin: number;
|
||
contentWidth: number;
|
||
footerY: number;
|
||
contentMinY: number;
|
||
headerDividerY: number;
|
||
colors: {
|
||
navy: ReturnType<typeof rgb>;
|
||
mediumGray: ReturnType<typeof rgb>;
|
||
darkGray: ReturnType<typeof rgb>;
|
||
almostWhite: ReturnType<typeof rgb>;
|
||
lightGray: ReturnType<typeof rgb>;
|
||
headerBg: ReturnType<typeof rgb>;
|
||
};
|
||
fonts: {
|
||
regular: PDFFont;
|
||
bold: PDFFont;
|
||
};
|
||
labels: ReturnType<typeof getLabels>;
|
||
product: ProductData;
|
||
locale: 'en' | 'de';
|
||
logoImage: PDFImage | null;
|
||
qrImage: PDFImage | null;
|
||
qrUrl: string;
|
||
};
|
||
|
||
function drawFooter(ctx: SectionDrawContext): void {
|
||
const { page, width, margin, footerY, fonts, colors, locale } = ctx;
|
||
|
||
page.drawLine({
|
||
start: { x: margin, y: footerY + 14 },
|
||
end: { x: width - margin, y: footerY + 14 },
|
||
thickness: 0.75,
|
||
color: colors.lightGray,
|
||
});
|
||
|
||
// Left: site URL (always)
|
||
page.drawText(CONFIG.siteUrl, {
|
||
x: margin,
|
||
y: footerY,
|
||
size: 8,
|
||
font: fonts.regular,
|
||
color: colors.mediumGray,
|
||
});
|
||
|
||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
});
|
||
|
||
// Right: date + page number (page number filled in after rendering)
|
||
const rightText = dateStr;
|
||
page.drawText(rightText, {
|
||
x: width - margin - fonts.regular.widthOfTextAtSize(rightText, 8),
|
||
y: footerY,
|
||
size: 8,
|
||
font: fonts.regular,
|
||
color: colors.mediumGray,
|
||
});
|
||
}
|
||
|
||
function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colors: { mediumGray: ReturnType<typeof rgb> }, margin: number, footerY: number): void {
|
||
const pages = pdfDoc.getPages();
|
||
const total = pages.length;
|
||
for (let i = 0; i < total; i++) {
|
||
const page = pages[i];
|
||
const { width } = page.getSize();
|
||
const text = `${i + 1}/${total}`;
|
||
page.drawText(text, {
|
||
x: width - margin - fonts.regular.widthOfTextAtSize(text, 8),
|
||
y: footerY - 12,
|
||
size: 8,
|
||
font: fonts.regular,
|
||
color: colors.mediumGray,
|
||
});
|
||
}
|
||
}
|
||
|
||
function drawHeader(ctx: SectionDrawContext, yStart: number): number {
|
||
const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx;
|
||
|
||
// Cable-industry look: calm, engineered header with right-aligned meta.
|
||
const headerH = 64;
|
||
const dividerY = yStart - headerH;
|
||
ctx.headerDividerY = dividerY;
|
||
|
||
page.drawRectangle({
|
||
x: 0,
|
||
y: dividerY,
|
||
width,
|
||
height: headerH,
|
||
color: colors.headerBg,
|
||
});
|
||
|
||
const qrSize = 44;
|
||
const qrGap = 12;
|
||
const rightReserved = qrImage ? qrSize + qrGap : 0;
|
||
|
||
// Left: logo (preferred) or typographic fallback
|
||
if (logoImage) {
|
||
const maxLogoW = 120;
|
||
const maxLogoH = 30;
|
||
const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
|
||
const w = logoImage.width * scale;
|
||
const h = logoImage.height * scale;
|
||
const logoY = dividerY + Math.round((headerH - h) / 2);
|
||
page.drawImage(logoImage, {
|
||
x: margin,
|
||
y: logoY,
|
||
width: w,
|
||
height: h,
|
||
});
|
||
} else {
|
||
const baseY = dividerY + 22;
|
||
page.drawText('KLZ', {
|
||
x: margin,
|
||
y: baseY,
|
||
size: 22,
|
||
font: fonts.bold,
|
||
color: colors.navy,
|
||
});
|
||
page.drawText('Cables', {
|
||
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
|
||
y: baseY + 2,
|
||
size: 10,
|
||
font: fonts.regular,
|
||
color: colors.mediumGray,
|
||
});
|
||
}
|
||
|
||
// Right: datasheet meta + QR (if available)
|
||
const metaRightEdge = width - margin - rightReserved;
|
||
const metaTitle = labels.datasheet;
|
||
const metaTitleSize = 9;
|
||
const metaSkuSize = 8;
|
||
const skuText = product.sku ? `${labels.sku}: ${stripHtml(product.sku)}` : '';
|
||
|
||
const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
|
||
page.drawText(metaTitle, {
|
||
x: metaRightEdge - mtW,
|
||
y: dividerY + 38,
|
||
size: metaTitleSize,
|
||
font: fonts.bold,
|
||
color: colors.navy,
|
||
});
|
||
|
||
if (skuText) {
|
||
const skuW = fonts.regular.widthOfTextAtSize(skuText, metaSkuSize);
|
||
page.drawText(skuText, {
|
||
x: metaRightEdge - skuW,
|
||
y: dividerY + 24,
|
||
size: metaSkuSize,
|
||
font: fonts.regular,
|
||
color: colors.mediumGray,
|
||
});
|
||
}
|
||
|
||
if (qrImage) {
|
||
const qrX = width - margin - qrSize;
|
||
const qrY = dividerY + Math.round((headerH - qrSize) / 2);
|
||
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
|
||
} else {
|
||
// If QR generation failed, keep the URL available as a compact line.
|
||
const maxW = 260;
|
||
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
|
||
if (urlLines.length) {
|
||
const line = urlLines[0];
|
||
const w = fonts.regular.widthOfTextAtSize(line, 8);
|
||
page.drawText(line, {
|
||
x: width - margin - w,
|
||
y: dividerY + 12,
|
||
size: 8,
|
||
font: fonts.regular,
|
||
color: colors.mediumGray,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Divider line
|
||
page.drawLine({
|
||
start: { x: margin, y: dividerY },
|
||
end: { x: margin + contentWidth, y: dividerY },
|
||
thickness: 0.75,
|
||
color: colors.lightGray,
|
||
});
|
||
|
||
// Content start: provide real breathing room below the header.
|
||
return dividerY - 40;
|
||
}
|
||
|
||
function drawCrossSectionChipsRow(args: {
|
||
title: string;
|
||
configRows: string[];
|
||
locale: 'en' | 'de';
|
||
getPage: () => PDFPage;
|
||
page: PDFPage;
|
||
y: number;
|
||
margin: number;
|
||
contentWidth: number;
|
||
contentMinY: number;
|
||
font: PDFFont;
|
||
fontBold: PDFFont;
|
||
navy: ReturnType<typeof rgb>;
|
||
darkGray: ReturnType<typeof rgb>;
|
||
mediumGray: ReturnType<typeof rgb>;
|
||
lightGray: ReturnType<typeof rgb>;
|
||
almostWhite: ReturnType<typeof rgb>;
|
||
}): number {
|
||
let { title, configRows, locale, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite } = args;
|
||
|
||
// Single-page rule: if we can't fit the block, stop.
|
||
const titleH = 12;
|
||
const summaryH = 12;
|
||
const chipH = 16;
|
||
const lineGap = 8;
|
||
const gapY = 10;
|
||
const minLines = 2;
|
||
const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY;
|
||
if (y - needed < contentMinY) return contentMinY - 1;
|
||
|
||
page = getPage();
|
||
|
||
// Normalize: keep only cross-section part, de-dupe, sort.
|
||
const itemsRaw = configRows
|
||
.map(r => splitConfig(r).crossSection)
|
||
.map(s => normalizeValue(s))
|
||
.filter(Boolean);
|
||
|
||
const seen = new Set<string>();
|
||
const items = itemsRaw.filter(v => (seen.has(v) ? false : (seen.add(v), true)));
|
||
|
||
items.sort((a, b) => {
|
||
const pa = parseCoresAndMm2(a);
|
||
const pb = parseCoresAndMm2(b);
|
||
if (pa.cores !== null && pb.cores !== null && pa.cores !== pb.cores) return pa.cores - pb.cores;
|
||
if (pa.mm2 !== null && pb.mm2 !== null && pa.mm2 !== pb.mm2) return pa.mm2 - pb.mm2;
|
||
return a.localeCompare(b);
|
||
});
|
||
|
||
const total = items.length;
|
||
const parsed = items.map(parseCoresAndMm2).filter(p => p.cores !== null && p.mm2 !== null) as Array<{ cores: number; mm2: number }>;
|
||
const uniqueCores = Array.from(new Set(parsed.map(p => p.cores))).sort((a, b) => a - b);
|
||
const mm2Vals = parsed.map(p => p.mm2).sort((a, b) => a - b);
|
||
const mm2Min = mm2Vals.length ? mm2Vals[0] : null;
|
||
const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null;
|
||
|
||
page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy });
|
||
y -= titleH;
|
||
|
||
const summaryParts: string[] = [];
|
||
summaryParts.push(locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`);
|
||
if (uniqueCores.length) summaryParts.push((locale === 'de' ? 'Adern' : 'Cores') + `: ${uniqueCores.join(', ')}`);
|
||
if (mm2Min !== null && mm2Max !== null) summaryParts.push(`mm²: ${mm2Min}${mm2Max !== mm2Min ? `–${mm2Max}` : ''}`);
|
||
page.drawText(summaryParts.join(' · '), { x: margin, y, size: 8, font, color: mediumGray, maxWidth: contentWidth });
|
||
y -= summaryH;
|
||
|
||
// Tags (wrapping). Rectangular, engineered (no playful rounding).
|
||
const padX = 8;
|
||
const chipFontSize = 8;
|
||
const chipGap = 8;
|
||
const chipPadTop = 5;
|
||
|
||
const startY = y - chipH; // baseline for first chip row
|
||
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
|
||
|
||
const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2;
|
||
|
||
type Placement = { text: string; x: number; y: number; w: number; variant: 'normal' | 'more' };
|
||
|
||
const layout = (texts: string[], includeMoreChip: boolean, moreText: string): { placements: Placement[]; shown: number } => {
|
||
const placements: Placement[] = [];
|
||
let x = margin;
|
||
let line = 0;
|
||
let cy = startY;
|
||
|
||
const advanceLine = () => {
|
||
line += 1;
|
||
if (line >= maxLines) return false;
|
||
x = margin;
|
||
cy -= chipH + lineGap;
|
||
return true;
|
||
};
|
||
|
||
const tryPlace = (text: string, variant: 'normal' | 'more'): boolean => {
|
||
const w = chipWidth(text);
|
||
if (w > contentWidth) return false;
|
||
if (x + w > margin + contentWidth) {
|
||
if (!advanceLine()) return false;
|
||
}
|
||
placements.push({ text, x, y: cy, w, variant });
|
||
x += w + chipGap;
|
||
return true;
|
||
};
|
||
|
||
let shown = 0;
|
||
for (let i = 0; i < texts.length; i++) {
|
||
if (!tryPlace(texts[i], 'normal')) break;
|
||
shown++;
|
||
}
|
||
|
||
if (includeMoreChip) {
|
||
tryPlace(moreText, 'more');
|
||
}
|
||
return { placements, shown };
|
||
};
|
||
|
||
// Group by cores: label on the left, mm² tags to the right.
|
||
const byCores = new Map<number, number[]>();
|
||
const other: string[] = [];
|
||
for (const cs of items) {
|
||
const p = parseCoresAndMm2(cs);
|
||
if (p.cores !== null && p.mm2 !== null) {
|
||
const arr = byCores.get(p.cores) ?? [];
|
||
arr.push(p.mm2);
|
||
byCores.set(p.cores, arr);
|
||
} else {
|
||
other.push(cs);
|
||
}
|
||
}
|
||
|
||
const coreKeys = Array.from(byCores.keys()).sort((a, b) => a - b);
|
||
for (const k of coreKeys) {
|
||
const uniq = Array.from(new Set(byCores.get(k) ?? [])).sort((a, b) => a - b);
|
||
byCores.set(k, uniq);
|
||
}
|
||
|
||
const fmtMm2 = (v: number) => {
|
||
const s = Number.isInteger(v) ? String(v) : String(v).replace(/\.0+$/, '');
|
||
return s;
|
||
};
|
||
|
||
// Layout engine with group labels.
|
||
const labelW = 38;
|
||
const placements: Placement[] = [];
|
||
let line = 0;
|
||
let cy = startY;
|
||
let x = margin + labelW;
|
||
|
||
const canAdvanceLine = () => line + 1 < maxLines;
|
||
const advanceLine = () => {
|
||
if (!canAdvanceLine()) return false;
|
||
line += 1;
|
||
cy -= chipH + lineGap;
|
||
x = margin + labelW;
|
||
return true;
|
||
};
|
||
|
||
const drawGroupLabel = (label: string) => {
|
||
// Draw label on each new line for the group (keeps readability when wrapping).
|
||
page.drawText(label, {
|
||
x: margin,
|
||
y: cy + 4,
|
||
size: 8,
|
||
font: fontBold,
|
||
color: mediumGray,
|
||
maxWidth: labelW - 4,
|
||
});
|
||
};
|
||
|
||
const placeChip = (text: string, variant: 'normal' | 'more') => {
|
||
const w = chipWidth(text);
|
||
if (w > contentWidth - labelW) return false;
|
||
if (x + w > margin + contentWidth) {
|
||
if (!advanceLine()) return false;
|
||
}
|
||
placements.push({ text, x, y: cy, w, variant });
|
||
x += w + chipGap;
|
||
return true;
|
||
};
|
||
|
||
let truncated = false;
|
||
let renderedCount = 0;
|
||
const totalChips = coreKeys.reduce((sum, k) => sum + (byCores.get(k)?.length ?? 0), 0) + other.length;
|
||
|
||
for (const cores of coreKeys) {
|
||
const values = byCores.get(cores) ?? [];
|
||
const label = `${cores}×`;
|
||
// Ensure label is shown at least once per line block.
|
||
drawGroupLabel(label);
|
||
for (const v of values) {
|
||
const ok = placeChip(fmtMm2(v), 'normal');
|
||
if (!ok) {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
renderedCount++;
|
||
}
|
||
if (truncated) break;
|
||
// Add a tiny gap between core groups (only if we have room on the current line)
|
||
x += 4;
|
||
if (x > margin + contentWidth - 20) {
|
||
if (!advanceLine()) {
|
||
// out of vertical space; stop
|
||
truncated = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!truncated && other.length) {
|
||
const label = locale === 'de' ? 'Sonst.' : 'Other';
|
||
drawGroupLabel(label);
|
||
for (const t of other) {
|
||
const ok = placeChip(t, 'normal');
|
||
if (!ok) {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
renderedCount++;
|
||
}
|
||
}
|
||
|
||
if (truncated) {
|
||
const remaining = Math.max(0, totalChips - renderedCount);
|
||
const moreText = locale === 'de' ? `+${remaining} weitere` : `+${remaining} more`;
|
||
// Try to place on current line; if not possible, try next line.
|
||
if (!placeChip(moreText, 'more')) {
|
||
if (advanceLine()) {
|
||
placeChip(moreText, 'more');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw placements
|
||
for (const p of placements) {
|
||
page.drawRectangle({
|
||
x: p.x,
|
||
y: p.y,
|
||
width: p.w,
|
||
height: chipH,
|
||
borderColor: lightGray,
|
||
borderWidth: 1,
|
||
color: rgb(1, 1, 1),
|
||
});
|
||
page.drawText(p.text, {
|
||
x: p.x + padX,
|
||
y: p.y + chipPadTop,
|
||
size: chipFontSize,
|
||
font,
|
||
color: p.variant === 'more' ? navy : darkGray,
|
||
maxWidth: p.w - padX * 2,
|
||
});
|
||
}
|
||
|
||
// Return cursor below the last line drawn
|
||
const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1;
|
||
const bottomY = startY - (linesUsed - 1) * (chipH + lineGap);
|
||
// Consistent section spacing after block.
|
||
// IMPORTANT: never return below contentMinY if we actually rendered,
|
||
// otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines).
|
||
return Math.max(bottomY - 24, contentMinY);
|
||
}
|
||
|
||
function drawCompactList(args: {
|
||
items: string[];
|
||
x: number;
|
||
y: number;
|
||
colW: number;
|
||
cols: number;
|
||
rowH: number;
|
||
maxRows: number;
|
||
page: PDFPage;
|
||
font: PDFFont;
|
||
fontSize: number;
|
||
color: ReturnType<typeof rgb>;
|
||
}): number {
|
||
const { items, x, colW, cols, rowH, maxRows, page, font, fontSize, color } = args;
|
||
let y = args.y;
|
||
const shown = items.slice(0, cols * maxRows);
|
||
for (let i = 0; i < shown.length; i++) {
|
||
const col = Math.floor(i / maxRows);
|
||
const row = i % maxRows;
|
||
const ix = x + col * colW;
|
||
const iy = y - row * rowH;
|
||
page.drawText(shown[i], {
|
||
x: ix,
|
||
y: iy,
|
||
size: fontSize,
|
||
font,
|
||
color,
|
||
maxWidth: colW - 6,
|
||
});
|
||
}
|
||
return y - maxRows * rowH;
|
||
}
|
||
|
||
function findAttr(product: ProductData, includes: RegExp): ProductData['attributes'][number] | undefined {
|
||
return product.attributes?.find(a => includes.test(a.name));
|
||
}
|
||
|
||
function normalizeValue(value: string): string {
|
||
return stripHtml(value).replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function splitConfig(config: string): { crossSection: string; voltage: string } {
|
||
const raw = normalizeValue(config);
|
||
const parts = raw.split(/\s*-\s*/);
|
||
if (parts.length >= 2) {
|
||
return { crossSection: parts[0], voltage: parts.slice(1).join(' - ') };
|
||
}
|
||
return { crossSection: raw, voltage: '' };
|
||
}
|
||
|
||
function parseCoresAndMm2(crossSection: string): { cores: number | null; mm2: number | null } {
|
||
const s = normalizeValue(crossSection)
|
||
.replace(/\s+/g, '')
|
||
.replace(/×/g, 'x')
|
||
.replace(/,/g, '.');
|
||
|
||
// Typical: 3x1.5, 4x25, 1x70
|
||
const m = s.match(/(\d{1,3})x(\d{1,4}(?:\.\d{1,2})?)/i);
|
||
if (!m) return { cores: null, mm2: null };
|
||
const cores = Number(m[1]);
|
||
const mm2 = Number(m[2]);
|
||
return {
|
||
cores: Number.isFinite(cores) ? cores : null,
|
||
mm2: Number.isFinite(mm2) ? mm2 : null,
|
||
};
|
||
}
|
||
|
||
async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<Buffer> {
|
||
try {
|
||
const labels = getLabels(locale);
|
||
const pdfDoc = await PDFDocument.create();
|
||
const pageSize: [number, number] = [595.28, 841.89]; // A4
|
||
let page = pdfDoc.addPage(pageSize);
|
||
const { width, height } = page.getSize();
|
||
|
||
// STYLEGUIDE.md colors
|
||
const navy = rgb(0.0549, 0.1647, 0.2784); // #0E2A47
|
||
const mediumGray = rgb(0.4196, 0.4471, 0.5020); // #6B7280
|
||
const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933
|
||
const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
|
||
const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED
|
||
const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint
|
||
|
||
// Small design system: consistent type + spacing for professional datasheets.
|
||
const DS = {
|
||
space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
|
||
type: { h1: 20, h2: 11, body: 10.5, small: 8 },
|
||
rule: { thin: 0.75 },
|
||
} as const;
|
||
|
||
// Line-heights (explicit so vertical rhythm doesn't drift / overlap)
|
||
const LH = {
|
||
h1: 24,
|
||
h2: 16,
|
||
body: 14,
|
||
small: 10,
|
||
} as const;
|
||
|
||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||
|
||
// Assets
|
||
// Prefer a raster logo for reliability (sharp SVG support can vary between environments).
|
||
const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg'));
|
||
const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null;
|
||
|
||
// Some products in the processed dataset have no images/attributes.
|
||
// Always fall back to a deterministic site hero so the PDF is never "empty".
|
||
const fallbackHero = '/media/10648-low-voltage-scaled.webp';
|
||
const heroSrc = product.featuredImage || product.images?.[0] || fallbackHero;
|
||
const heroPng = await loadEmbeddablePng(heroSrc);
|
||
|
||
const productUrl = getProductUrl(product) || CONFIG.siteUrl;
|
||
const qrPng = await loadQrPng(productUrl);
|
||
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
|
||
|
||
// Engineered page frame (A4): slightly narrower margins but consistent rhythm.
|
||
const margin = 54;
|
||
const footerY = 54;
|
||
const contentMinY = footerY + 42; // keep clear of footer + page numbers
|
||
const contentWidth = width - 2 * margin;
|
||
|
||
const ctx: SectionDrawContext = {
|
||
pdfDoc,
|
||
page,
|
||
width,
|
||
height,
|
||
margin,
|
||
contentWidth,
|
||
footerY,
|
||
contentMinY,
|
||
headerDividerY: 0,
|
||
colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
|
||
fonts: { regular: font, bold: fontBold },
|
||
labels,
|
||
product,
|
||
locale,
|
||
logoImage,
|
||
qrImage,
|
||
qrUrl: productUrl,
|
||
};
|
||
|
||
// Hard requirement: one-page PDFs.
|
||
// We never create a second page; we truncate sections to fit.
|
||
const newPage = (): number => contentMinY - 1;
|
||
const hasSpace = (needed: number) => y - needed >= contentMinY;
|
||
|
||
// ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ----
|
||
const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => {
|
||
// One-page rule: if we can't fit a divider with its spacing, do nothing.
|
||
if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return;
|
||
|
||
y -= gapAbove;
|
||
page.drawLine({
|
||
start: { x: margin, y },
|
||
end: { x: margin + contentWidth, y },
|
||
thickness: DS.rule.thin,
|
||
color: lightGray,
|
||
});
|
||
y -= gapBelow;
|
||
};
|
||
|
||
const sectionTitle = (text: string) => {
|
||
// One-page rule: if we can't fit the heading + its gap, do nothing.
|
||
if (!hasSpace(DS.type.h2 + DS.space.md)) return;
|
||
|
||
page.drawText(text, {
|
||
x: margin,
|
||
y,
|
||
size: DS.type.h2,
|
||
font: fontBold,
|
||
color: navy,
|
||
});
|
||
// Use a real line-height to avoid title/body overlap.
|
||
y -= LH.h2;
|
||
};
|
||
|
||
// Page 1
|
||
// Page background (print-friendly)
|
||
page.drawRectangle({
|
||
x: 0,
|
||
y: 0,
|
||
width,
|
||
height,
|
||
color: rgb(1, 1, 1),
|
||
});
|
||
|
||
drawFooter(ctx);
|
||
let y = drawHeader(ctx, height - margin);
|
||
|
||
// === PRODUCT HEADER ===
|
||
const productName = stripHtml(product.name);
|
||
const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • ');
|
||
|
||
const titleW = contentWidth;
|
||
const titleLineH = LH.h1;
|
||
const nameLines = wrapText(productName, fontBold, DS.type.h1, titleW);
|
||
const shownNameLines = nameLines.slice(0, 2);
|
||
for (const line of shownNameLines) {
|
||
if (y - titleLineH < contentMinY) y = newPage();
|
||
page.drawText(line, {
|
||
x: margin,
|
||
y,
|
||
size: DS.type.h1,
|
||
font: fontBold,
|
||
color: navy,
|
||
maxWidth: titleW,
|
||
});
|
||
y -= titleLineH;
|
||
}
|
||
|
||
if (cats) {
|
||
if (y - 18 < contentMinY) y = newPage();
|
||
page.drawText(cats, {
|
||
x: margin,
|
||
y,
|
||
size: 10.5,
|
||
font,
|
||
color: mediumGray,
|
||
maxWidth: titleW,
|
||
});
|
||
y -= DS.space.lg;
|
||
}
|
||
|
||
// Separator after product header
|
||
rule(DS.space.sm, DS.space.lg);
|
||
|
||
// === HERO IMAGE (full width) ===
|
||
let heroH = 160;
|
||
const afterHeroGap = DS.space.xl;
|
||
if (!hasSpace(heroH + afterHeroGap)) {
|
||
// Shrink to remaining space (but keep it usable).
|
||
heroH = Math.max(120, Math.floor(y - contentMinY - afterHeroGap));
|
||
}
|
||
|
||
const heroBoxX = margin;
|
||
const heroBoxY = y - heroH;
|
||
page.drawRectangle({
|
||
x: heroBoxX,
|
||
y: heroBoxY,
|
||
width: contentWidth,
|
||
height: heroH,
|
||
// Calm frame; gives images consistent presence even with transparency.
|
||
color: almostWhite,
|
||
borderColor: lightGray,
|
||
borderWidth: 1,
|
||
});
|
||
|
||
if (heroPng) {
|
||
const pad = DS.space.md;
|
||
const boxW = contentWidth - pad * 2;
|
||
const boxH = heroH - pad * 2;
|
||
|
||
// Pre-crop the image to the target aspect ratio (prevents overflow and removes top/bottom whitespace).
|
||
const sharp = await getSharp();
|
||
const cropped = await sharp(Buffer.from(heroPng.pngBytes))
|
||
.resize({
|
||
width: 1200,
|
||
height: Math.round((1200 * boxH) / boxW),
|
||
fit: 'cover',
|
||
position: 'attention',
|
||
})
|
||
.png()
|
||
.toBuffer();
|
||
const heroImage = await pdfDoc.embedPng(cropped);
|
||
|
||
// Exact-fit (we already cropped to this aspect ratio).
|
||
page.drawImage(heroImage, {
|
||
x: heroBoxX + pad,
|
||
y: heroBoxY + pad,
|
||
width: boxW,
|
||
height: boxH,
|
||
});
|
||
} else {
|
||
page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', {
|
||
x: heroBoxX + 12,
|
||
y: heroBoxY + heroH / 2,
|
||
size: 8,
|
||
font,
|
||
color: mediumGray,
|
||
maxWidth: contentWidth - 24,
|
||
});
|
||
}
|
||
|
||
y = heroBoxY - afterHeroGap;
|
||
|
||
// === DESCRIPTION ===
|
||
if (product.shortDescriptionHtml || product.descriptionHtml) {
|
||
const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
|
||
const descLineH = 14;
|
||
const descMaxLines = 3;
|
||
const boxPadX = DS.space.md;
|
||
const boxPadY = DS.space.md;
|
||
const boxH = boxPadY * 2 + descLineH * descMaxLines;
|
||
const descNeeded = DS.type.h2 + DS.space.md + boxH + DS.space.lg + DS.space.xl;
|
||
|
||
// One-page rule: only render description if we can fit it cleanly.
|
||
if (hasSpace(descNeeded)) {
|
||
sectionTitle(labels.description);
|
||
|
||
const boxTop = y + DS.space.xs;
|
||
const boxBottom = boxTop - boxH;
|
||
page.drawRectangle({
|
||
x: margin,
|
||
y: boxBottom,
|
||
width: contentWidth,
|
||
height: boxH,
|
||
color: rgb(1, 1, 1),
|
||
borderColor: lightGray,
|
||
borderWidth: 1,
|
||
});
|
||
|
||
const descLines = wrapText(desc, font, DS.type.body, contentWidth - boxPadX * 2);
|
||
let ty = boxTop - boxPadY - DS.type.body;
|
||
for (const line of descLines.slice(0, descMaxLines)) {
|
||
page.drawText(line, {
|
||
x: margin + boxPadX,
|
||
y: ty,
|
||
size: DS.type.body,
|
||
font,
|
||
color: darkGray,
|
||
maxWidth: contentWidth - boxPadX * 2,
|
||
});
|
||
ty -= descLineH;
|
||
}
|
||
|
||
y = boxBottom - DS.space.lg;
|
||
rule(0, DS.space.xl);
|
||
}
|
||
}
|
||
|
||
// === TECHNICAL DATA (shared across all cross-sections) ===
|
||
const configAttr = findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i);
|
||
const crossSectionAttr =
|
||
configAttr ||
|
||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
|
||
const rowCount = crossSectionAttr?.options?.length || 0;
|
||
const hasCrossSectionData = Boolean(crossSectionAttr && rowCount > 0);
|
||
|
||
// Compact mode approach:
|
||
// - show constant (non-row) attributes as key/value grid
|
||
// - show only a small configuration sample + total count
|
||
// - optionally render full tables with PDF_MODE=full
|
||
|
||
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
|
||
const constantItemsAll = constantAttrs
|
||
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
|
||
.filter(i => i.label && i.value)
|
||
.slice(0, 12);
|
||
|
||
// Intentionally do NOT include SKU/categories here (they are already shown in the product header).
|
||
|
||
// TECH DATA must never crowd out cross-section.
|
||
// IMPORTANT: `drawKeyValueGrid()` will return `contentMinY - 1` when it can't fit.
|
||
// We must avoid calling it unless we're sure it fits.
|
||
const techBox = {
|
||
// Keep in sync with `drawKeyValueGrid()` boxed metrics
|
||
padY: 14,
|
||
headerH: 22,
|
||
rowH: 24,
|
||
} as const;
|
||
|
||
// Reserve enough space so cross-sections are actually visible when present.
|
||
// Mirror `drawCrossSectionChipsRow()` minimum-needed math (+ a bit of padding).
|
||
const minCrossBlockH = 12 /*title*/ + 12 /*summary*/ + (16 * 2) /*chips*/ + 8 /*lineGap*/ + 10 /*gapY*/ + 24 /*after*/;
|
||
const reservedForCross = hasCrossSectionData ? minCrossBlockH : 0;
|
||
|
||
const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
|
||
|
||
const techBoxHeightFor = (itemsCount: number) => {
|
||
const rows = Math.ceil(itemsCount / 2);
|
||
return techBox.padY + techBox.headerH + rows * techBox.rowH + techBox.padY;
|
||
};
|
||
|
||
const canFitTechWith = (itemsCount: number) => {
|
||
if (itemsCount <= 0) return false;
|
||
const techH = techBoxHeightFor(itemsCount);
|
||
const afterTechGap = DS.space.lg;
|
||
// We need to keep reserved space for cross-section below.
|
||
return y - (techH + afterTechGap + reservedForCross) >= contentMinY;
|
||
};
|
||
|
||
// Pick the largest "nice" amount of items that still guarantees cross-section visibility.
|
||
const desiredCap = 8;
|
||
let chosenCount = 0;
|
||
for (let n = Math.min(desiredCap, constantItemsAll.length); n >= 1; n--) {
|
||
if (canFitTechWith(n)) {
|
||
chosenCount = n;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (chosenCount > 0) {
|
||
const constantItems = constantItemsAll.slice(0, chosenCount);
|
||
y = drawKeyValueGrid({
|
||
title: techTitle,
|
||
items: constantItems,
|
||
newPage,
|
||
getPage: () => page,
|
||
page,
|
||
y,
|
||
margin,
|
||
contentWidth,
|
||
contentMinY,
|
||
font,
|
||
fontBold,
|
||
navy,
|
||
darkGray,
|
||
mediumGray,
|
||
lightGray,
|
||
almostWhite,
|
||
allowNewPage: false,
|
||
boxed: true,
|
||
});
|
||
} else if (!hasCrossSectionData) {
|
||
// If there is no cross-section block, we can afford to show a clear "no data" note.
|
||
y = drawKeyValueGrid({
|
||
title: techTitle,
|
||
items: [
|
||
{
|
||
label: locale === 'de' ? 'Hinweis' : 'Note',
|
||
value:
|
||
locale === 'de'
|
||
? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.'
|
||
: 'No technical data is available for this product yet.',
|
||
},
|
||
],
|
||
newPage,
|
||
getPage: () => page,
|
||
page,
|
||
y,
|
||
margin,
|
||
contentWidth,
|
||
contentMinY,
|
||
font,
|
||
fontBold,
|
||
navy,
|
||
darkGray,
|
||
mediumGray,
|
||
lightGray,
|
||
almostWhite,
|
||
allowNewPage: false,
|
||
boxed: true,
|
||
});
|
||
}
|
||
|
||
// Consistent spacing after the technical data block (but never push content below min Y)
|
||
if (y - DS.space.lg >= contentMinY) y -= DS.space.lg;
|
||
|
||
// === CROSS-SECTION TABLE (row-specific data) ===
|
||
if (crossSectionAttr && rowCount > 0) {
|
||
const configRows = crossSectionAttr.options;
|
||
|
||
const findRowAttr = (re: RegExp) => {
|
||
const a = product.attributes?.find(x => re.test(x.name));
|
||
if (!a) return null;
|
||
if (!a.options || a.options.length !== rowCount) return null;
|
||
return a;
|
||
};
|
||
|
||
const candidateCols: Array<{ key: string; label: string; re: RegExp }> = [
|
||
{ key: 'outerDiameter', label: locale === 'de' ? 'Außen-Ø' : 'Outer Ø', re: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i },
|
||
{ key: 'weight', label: locale === 'de' ? 'Gewicht' : 'Weight', re: /\bweight\b|gewicht/i },
|
||
{ key: 'maxResistance', label: locale === 'de' ? 'Max. Leiterwiderstand' : 'Max. conductor resistance', re: /maximum\s+resistance\s+of\s+conductor|max\.?\s*resistance|leiterwiderstand/i },
|
||
{ key: 'current', label: locale === 'de' ? 'Strombelastbarkeit' : 'Current rating', re: /current\s*(rating|carrying)|ampacity|strombelastbarkeit/i },
|
||
];
|
||
|
||
// NOTE: One-page requirement: cross sections render as a dense list only.
|
||
// Row-specific values are intentionally omitted to keep the sheet compact.
|
||
const columns: Array<{ label: string; get: (rowIndex: number) => string }> = [];
|
||
|
||
const yAfterCross = drawCrossSectionChipsRow({
|
||
title: labels.crossSection,
|
||
configRows,
|
||
locale,
|
||
getPage: () => page,
|
||
page,
|
||
y,
|
||
margin,
|
||
contentWidth,
|
||
contentMinY,
|
||
font,
|
||
fontBold,
|
||
navy,
|
||
darkGray,
|
||
mediumGray,
|
||
lightGray,
|
||
almostWhite,
|
||
});
|
||
|
||
// If the chips block can't fit at all, show a minimal summary line (no chips).
|
||
// drawCrossSectionChipsRow returns (contentMinY - 1) in that case.
|
||
if (yAfterCross < contentMinY) {
|
||
sectionTitle(labels.crossSection);
|
||
const total = configRows.length;
|
||
const summary = locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`;
|
||
page.drawText(summary, {
|
||
x: margin,
|
||
y,
|
||
size: DS.type.body,
|
||
font,
|
||
color: mediumGray,
|
||
maxWidth: contentWidth,
|
||
});
|
||
y -= LH.body + DS.space.lg;
|
||
} else {
|
||
y = yAfterCross;
|
||
}
|
||
} else {
|
||
// If there is no cross-section data, do not render the section at all.
|
||
}
|
||
|
||
// Add page numbers after all pages are created.
|
||
stampPageNumbers(pdfDoc, { regular: font }, { mediumGray }, margin, footerY);
|
||
|
||
const pdfBytes = await pdfDoc.save();
|
||
return Buffer.from(pdfBytes);
|
||
|
||
} catch (error: any) {
|
||
throw new Error(`Failed to generate PDF for product ${product.id} (${locale}): ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function processChunk(products: ProductData[], chunkIndex: number, totalChunks: number): Promise<void> {
|
||
console.log(`\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`);
|
||
|
||
for (const product of products) {
|
||
try {
|
||
const locale = product.locale || 'en';
|
||
const buffer = await generatePDF(product, locale);
|
||
const fileName = generateFileName(product, locale);
|
||
fs.writeFileSync(path.join(CONFIG.outputDir, fileName), buffer);
|
||
console.log(`✓ ${locale.toUpperCase()}: ${fileName}`);
|
||
await new Promise(resolve => setTimeout(resolve, 50));
|
||
} catch (error) {
|
||
console.error(`✗ Failed to process product ${product.id}:`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function readProductsStream(): Promise<ProductData[]> {
|
||
console.log('Reading products.json...');
|
||
return new Promise((resolve, reject) => {
|
||
const stream = fs.createReadStream(CONFIG.productsFile, { encoding: 'utf8' });
|
||
let data = '';
|
||
stream.on('data', (chunk) => { data += chunk; });
|
||
stream.on('end', () => {
|
||
try {
|
||
const products = JSON.parse(data);
|
||
console.log(`Loaded ${products.length} products`);
|
||
resolve(products);
|
||
} catch (error) {
|
||
reject(new Error(`Failed to parse JSON: ${error}`));
|
||
}
|
||
});
|
||
stream.on('error', (error) => reject(new Error(`Failed to read file: ${error}`)));
|
||
});
|
||
}
|
||
|
||
async function processProductsInChunks(): Promise<void> {
|
||
console.log('Starting PDF generation - Industrial engineering documentation style');
|
||
ensureOutputDir();
|
||
|
||
try {
|
||
const allProducts = await readProductsStream();
|
||
if (allProducts.length === 0) {
|
||
console.log('No products found');
|
||
return;
|
||
}
|
||
|
||
// Optional dev convenience: limit how many PDFs we render (useful for design iteration).
|
||
// Default behavior remains unchanged.
|
||
const limit = Number(process.env.PDF_LIMIT || '0');
|
||
const products = Number.isFinite(limit) && limit > 0 ? allProducts.slice(0, limit) : allProducts;
|
||
|
||
const enProducts = products.filter(p => p.locale === 'en');
|
||
const deProducts = products.filter(p => p.locale === 'de');
|
||
console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
|
||
|
||
const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
|
||
for (let i = 0; i < totalChunks; i++) {
|
||
const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
|
||
await processChunk(chunk, i, totalChunks);
|
||
}
|
||
|
||
console.log('\n✅ PDF generation completed!');
|
||
console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`);
|
||
console.log(`Output: ${CONFIG.outputDir}`);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const start = Date.now();
|
||
try {
|
||
await processProductsInChunks();
|
||
console.log(`\nTime: ${((Date.now() - start) / 1000).toFixed(2)}s`);
|
||
} catch (error) {
|
||
console.error('Fatal error:', error);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
main().catch(console.error);
|
||
|
||
export { main as generatePDFDatasheets };
|