/** * Karta produktu PDF — dokument kliencki (do wysyłki poza studio). * * A4 pion. Zasady: * - liczby WYŁĄCZNIE z faktów confirmed (jak blueprint — "—" nigdy nie * udaje wymiaru, brak potwierdzenia = pominięty wiersz), * - obrazy WYŁĄCZNIE z zatwierdzonych assetów (ta sama treść sha256, * którą zatwierdził approver — nie "pierwszy gotowy obraz"), * - język kliencki: bez wersji assetów, bez wzmianek o AI, bez fraz * wewnętrznych ("fakty potwierdzone", "recolor z mastera" itd.), * - dane handlowe oznaczone jako wewnętrzne (lead_time, sku_pattern, * vat_rate) nie wchodzą na kartę; cena — decyzja właściciela: bez ceny, * - parametry tkaniny tylko publikowalne (bez Easy Clean / Pet Friendly / * OEKO-TEX / trudnopalności — polityka karty TILIA), * - provenance: snapshot faktów + lista assetów z sha256 (w DB, nie na * wydruku — dokument ma wyglądać czysto). */ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { createRequire } from "node:module"; import sharp from "sharp"; import PDFDocument from "pdfkit"; import type { Db } from "../db.js"; import type { AppConfig } from "../config.js"; import { approvedAssetForRole, registerAsset, type AssetRow } from "./assets.js"; import { getFacts, type PublicFact } from "./facts.js"; import { parseFabricParams } from "./fabric.js"; import type { ShotRole, Side } from "./types.js"; // ---------- kolory/tokeny spójne z web/src/styles.css ---------- const INK = "#1f1c17"; const INK_SOFT = "#5c564d"; const INK_FAINT = "#8a8278"; const LINE = "#e4dfd6"; const ACCENT = "#8a5a2b"; const require_ = createRequire(import.meta.url); const FONT_DIR = path.join( path.dirname(require_.resolve("dejavu-fonts-ttf/package.json")), "ttf" ); const FONT = { display: path.join(FONT_DIR, "DejaVuSerif.ttf"), displayBold: path.join(FONT_DIR, "DejaVuSerif-Bold.ttf"), sans: path.join(FONT_DIR, "DejaVuSans.ttf"), sansBold: path.join(FONT_DIR, "DejaVuSans-Bold.ttf"), sansOblique: path.join(FONT_DIR, "DejaVuSans-Oblique.ttf"), }; const KIND_PL: Record = { "corner-sofa": "narożnik rozkładany", sofa: "sofa", armchair: "fotel", }; const MECHANISM_PL: Record = { seat_pullout_backfold: "siedzisko wysuwane + oparcie składane (funkcja spania)", dolphin: "delfin — wysuwany wózek spod siedziska", click_clack: "klik-klak — oparcie składane do poziomu siedziska", }; /** Kolejność i klienckie etykiety ujęć w siatce galerii (str. 2). */ const GALLERY_ORDER: Array<{ role: ShotRole; label: string }> = [ { role: "hero", label: "Aranżacja" }, { role: "packshot_front", label: "Widok z przodu" }, { role: "packshot_34", label: "Widok 3/4" }, { role: "side", label: "Z boku" }, { role: "back", label: "Z tyłu" }, { role: "sleep", label: "Funkcja spania" }, { role: "storage", label: "Pojemnik na pościel" }, { role: "detail", label: "Detal tkaniny" }, ]; export interface CardImage { role: ShotRole; label: string; assetId: string; sha256: string; version: number; absPath: string; } export interface VariantRow { code: string; name: string | null; colorHex: string | null; swatchAbs: string | null; // oficjalna próbka Davis (plik) thumbs: Partial>; // zatwierdzone packshot_34 per strona } export interface ProductCardData { product: { id: string; slug: string; name: string; kind: string }; fabricLabel: string; // np. "TILIA 86" fabricLines: string[]; // publikowalne parametry tkaniny fabricHeading: string | null; // np. "TILIA (Davis)" sidePhotographed: "L" | "P" | null; // provenance — nie drukowane na karcie cardSides: Side[]; // strony obecne w wariantach (kolejność L,P,S) — kolumny miniatur params: Array<[string, string]>; // kluczowe parametry — tylko confirmed packageLines: string[]; // paczki assemblyLine: string | null; identifiers: Array<[string, string]>; // SKU, EAN, gwarancja, produkcja hero: CardImage | null; gallery: CardImage[]; // zatwierdzone ujęcia bez hero głównego blueprint: CardImage | null; blueprintAiDrawn: boolean; // provenance.mode === "ai" — trafia do provenance dokumentu variants: VariantRow[]; // przegląd wariantów — próbki + miniatury zatwierdzonych packshotów missing: string[]; // braki blokujące generację factsSnapshot: Record; // confirmed fakty użyte na karcie version: number; // kolejna wersja dokumentu (dla stopki) generatedAt: string; } const num = (v: unknown): number | null => (typeof v === "number" ? v : null); const cmFmt = (v: number | null): string => v === null ? "—" : `${String(v).replace(".", ",")} cm`; /** ISO (YYYY-MM-DD) → format kliencki DD.MM.YYYY. */ const dateFmt = (iso: string): string => /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)?.slice(1).reverse().join(".") ?? iso; function toCardImage(a: AssetRow, mediaDir: string, label: string): CardImage { return { role: a.role, label, assetId: a.id, sha256: a.sha256, version: a.version, absPath: path.join(mediaDir, a.path), }; } /** * Zbiera dane karty: fakty confirmed + zatwierdzone assety. * `missing` opisuje blokady — route zwraca je jako 422 zamiast budować * dokument z pustymi obrazami. */ export function collectCardData(db: Db, productId: string, mediaDir: string): ProductCardData { const product = db .prepare("SELECT id, slug, name, kind FROM products WHERE id = ?") .get(productId) as unknown as { id: string; slug: string; name: string; kind: string }; const facts = getFacts(db, productId); const confirmed = new Map( facts.filter((f) => f.status === "confirmed").map((f) => [f.key, f]) ); const factsSnapshot: Record = {}; for (const [k, f] of confirmed) factsSnapshot[k] = f.value; const val = (key: string): unknown => confirmed.get(key)?.value; const numVal = (key: string): number | null => num(val(key)); // ---------- zatwierdzone obrazy ---------- const missing: string[] = []; const approved = new Map(); for (const { role } of GALLERY_ORDER) { const a = approvedAssetForRole(db, productId, role); if (a) approved.set(role, a); } const bpRow = approvedAssetForRole(db, productId, "blueprint"); if (!bpRow) missing.push("zatwierdzony rysunek wymiarowy (blueprint)"); const heroRow = approved.get("hero") ?? approved.get("packshot_34") ?? approved.get("packshot_front") ?? null; if (!heroRow) missing.push("zatwierdzone zdjęcie główne (hero / packshot_34 / packshot_front)"); const hero = heroRow ? toCardImage(heroRow, mediaDir, GALLERY_ORDER.find((g) => g.role === heroRow.role)?.label ?? "") : null; const gallery = GALLERY_ORDER.filter((g) => approved.has(g.role) && approved.get(g.role)!.id !== heroRow?.id).map( (g) => toCardImage(approved.get(g.role)!, mediaDir, g.label) ); const bpProv = bpRow ? (JSON.parse(bpRow.provenance_json) as { mode?: string }) : null; const blueprint = bpRow ? toCardImage(bpRow, mediaDir, "Rysunek wymiarowy") : null; // ---------- fakty → wiersze parametrów ---------- const w = numVal("dimensions.width_cm"); const d = numVal("dimensions.depth_cm"); const hc = numVal("dimensions.height_with_cushions_cm"); const hnc = numVal("dimensions.height_without_cushions_cm"); const bodyDepth = numVal("dimensions.body_depth_cm"); const seatH = numVal("dimensions.seat_height_cm"); const seatD = numVal("dimensions.seat_depth_cm"); const armW = numVal("dimensions.armrest_width_cm"); const sleepW = numVal("sleeping.width_cm"); const sleepL = numVal("sleeping.length_cm"); const legsH = numVal("legs.height_cm"); const cushions = numVal("cushions.loose_back_count"); const mech = val("mechanism.sleep"); const params: Array<[string, string]> = []; if (w !== null || d !== null || hc !== null) params.push(["Wymiary gabarytowe", `${cmFmt(w)} × ${cmFmt(d)} × ${cmFmt(hc)}`]); if (hnc !== null) params.push(["Wysokość bez poduszek", cmFmt(hnc)]); if (bodyDepth !== null) params.push(["Głębokość korpusu", cmFmt(bodyDepth)]); if (sleepW !== null || sleepL !== null) params.push(["Powierzchnia spania", `${cmFmt(sleepW)} × ${cmFmt(sleepL)}`]); if (seatH !== null || seatD !== null) params.push(["Siedzisko", `wys. ${cmFmt(seatH)} · gł. ${cmFmt(seatD)}`]); if (cushions !== null) params.push(["Luźne poduszki oparciowe", `${cushions} szt.`]); if (typeof mech === "string" && MECHANISM_PL[mech]) params.push(["Mechanizm rozkładania", MECHANISM_PL[mech]]); if (val("features.bedding_container") === true) params.push(["Pojemnik na pościel", "tak"]); if (armW !== null) params.push(["Szerokość podłokietnika", cmFmt(armW)]); if (legsH !== null) params.push(["Nóżki", `${cmFmt(legsH)} — w zestawie, montaż u klienta`]); const sides = val("sides.available"); if (Array.isArray(sides)) params.push([ "Strony", (sides as string[]).map((s) => (s === "L" ? "lewa" : s === "P" ? "prawa" : "bez strony")).join(" / "), ]); // ---------- paczki / montaż ---------- const packageLines: string[] = []; const packs = val("packages.items"); if (Array.isArray(packs)) { const parts = (packs as Array<{ dimensions_cm: number[]; weight_kg: number }>).map( (b) => `${b.dimensions_cm.join("×")} cm / ${b.weight_kg} kg` ); const total = numVal("packages.total_weight_kg"); packageLines.push( `${parts.length} ${parts.length === 1 ? "paczka" : "paczki"}: ${parts.join(" + ")}` + (total !== null ? ` — razem ${total} kg` : "") ); } const assembly = val("assembly.by_customer"); const assemblyLine = typeof assembly === "string" ? `Montaż: ${assembly}` : null; // ---------- identyfikatory ---------- const identifiers: Array<[string, string]> = []; const sku = val("commerce.sku"); if (typeof sku === "string") identifiers.push(["SKU", sku]); const ean = val("commerce.ean"); identifiers.push(["EAN", typeof ean === "string" ? ean : "do nadania"]); const warranty = numVal("warranty.months"); if (warranty !== null) identifiers.push(["Gwarancja", `${warranty} miesiące`]); const country = val("production.country"); if (typeof country === "string") identifiers.push(["Produkcja", `${country} — Vilmax`]); // ---------- tkanina: tylko publikowalne parametry ---------- const fabric = db .prepare( `SELECT f.name, f.producer, f.params_json, f.verification FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? LIMIT 1` ) .get(productId) as unknown as | { name: string; producer: string; params_json: string; verification: string } | undefined; const shade = val("fabric.photographed_shade"); const fabricLabel = [fabric?.name, typeof shade === "string" ? shade : ""].filter(Boolean).join(" ") || "—"; const side = val("sides.photographed"); const sidePhotographed = side === "L" || side === "P" ? side : null; const fabricLines: string[] = []; if (fabric && fabric.verification === "confirmed") { const p = parseFabricParams(fabric.params_json); if (typeof p.composition === "string") fabricLines.push(`Skład: ${p.composition}`); const g = p.grammage_g_m2 as { value?: number; tolerance?: string } | undefined; if (g?.value) fabricLines.push(`Gramatura: ${g.value} g/m²${g.tolerance ? ` ${g.tolerance}` : ""}`); const m = p.martindale_cycles as { min?: number; max?: number; category?: string } | undefined; if (m?.min && m?.max) fabricLines.push( `Ścieralność (Martindale): ${m.min.toLocaleString("pl-PL")}–${m.max.toLocaleString("pl-PL")} cykli${m.category ? ` — kat. ${m.category}` : ""}` ); if (typeof shade === "string") fabricLines.push(`Odcień na zdjęciach: ${fabric.name} ${shade}`); } // ---------- przegląd wariantów: próbka + zatwierdzone packshoty per strona ---------- const rawVariants = db .prepare( `SELECT v.id, v.side, v.shade_code, s.name, s.color_hex, s.official_image_path FROM variants v JOIN fabric_shades s ON s.fabric_id = v.fabric_id AND s.code = v.shade_code WHERE v.product_id = ? ORDER BY CAST(v.shade_code AS INTEGER), v.side` ) .all(productId) as unknown as Array<{ id: string; side: string; shade_code: string; name: string | null; color_hex: string | null; official_image_path: string | null; }>; const variantSides = new Set(rawVariants.map((v) => v.side)); const variantRows: VariantRow[] = rawVariants.reduce((acc, v) => { let row = acc.find((r) => r.code === v.shade_code); if (!row) { const sw = v.official_image_path ? (db.prepare("SELECT path FROM source_assets WHERE id = ?").get(v.official_image_path) as | { path: string } | undefined) : undefined; row = { code: v.shade_code, name: v.name, colorHex: v.color_hex, swatchAbs: sw ? path.join(mediaDir, "originals", sw.path) : null, thumbs: {}, }; acc.push(row); } const a = approvedAssetForRole(db, productId, "packshot_34", v.id); if (a) { row.thumbs[v.side as Side] = toCardImage(a, mediaDir, `Packshot 3/4 · ${v.side}${v.shade_code}`); } return acc; }, []); const maxV = db .prepare( "SELECT COALESCE(MAX(version), 0) AS v FROM assets WHERE product_id = ? AND role = 'document'" ) .get(productId) as unknown as { v: number }; return { product, fabricLabel, fabricLines, fabricHeading: fabric ? `${fabric.name} (${fabric.producer})` : null, sidePhotographed, cardSides: (["L", "P", "S"] as Side[]).filter((s) => variantSides.has(s)), params, packageLines, assemblyLine, identifiers, hero, gallery, blueprint, blueprintAiDrawn: bpProv?.mode === "ai", variants: variantRows, missing, factsSnapshot, version: maxV.v + 1, generatedAt: new Date().toISOString().slice(0, 10), }; } // ---------- render PDF ---------- const PAGE_W = 595.28; const PAGE_H = 841.89; const MARGIN = 42; const CONTENT_W = PAGE_W - 2 * MARGIN; async function toJpeg(absPath: string, boxW: number, boxH: number): Promise { return sharp(absPath) .rotate() .resize({ width: Math.round(boxW * 2), height: Math.round(boxH * 2), fit: "inside", withoutEnlargement: true, }) .flatten({ background: "#ffffff" }) .jpeg({ quality: 86 }) .toBuffer(); } /** Renderuje kartę do bufora PDF. Dane muszą być kompletne (missing puste). */ export async function renderProductCardPdf(data: ProductCardData): Promise { const doc = new PDFDocument({ size: "A4", margin: 0, bufferPages: true, info: { Title: `${data.product.name} — karta produktu`, Author: "VILMAX", Subject: "Karta produktu", }, }); for (const [name, p] of Object.entries(FONT)) doc.registerFont(name, p); const chunks: Buffer[] = []; doc.on("data", (c: Buffer) => chunks.push(c)); const done = new Promise((resolve, reject) => { doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); }); let y = MARGIN; const kindPl = KIND_PL[data.product.kind] ?? data.product.kind; // stopka stemplowana na końcu na każdej stronie (bufferedPageRange) — // numeracja poprawna nawet gdy treść przepełni docelowe strony const stampFooter = (pageNo: number, total: number) => { rule(MARGIN, PAGE_H - 46, PAGE_W - MARGIN, LINE, 0.6); doc .font("sans") .fontSize(7.5) .fillColor(INK_FAINT) .text( `VILMAX · karta produktu · ${dateFmt(data.generatedAt)}`, MARGIN, PAGE_H - 34, { width: CONTENT_W - 60 } ) .text(`${pageNo} / ${total}`, PAGE_W - MARGIN - 60, PAGE_H - 34, { width: 60, align: "right", }); }; const rule = (x1: number, yy: number, x2: number, color = LINE, wdt = 0.7) => { doc.moveTo(x1, yy).lineTo(x2, yy).lineWidth(wdt).strokeColor(color).stroke(); }; const sectionTitle = (yy: number, t: string) => { doc .font("sansBold") .fontSize(10) .fillColor(ACCENT) .text(t, MARGIN, yy, { characterSpacing: 1.6 }); }; // tabela parametrów: etykieta lewo / wartość prawo, hairline między wierszami const paramTable = ( rows: Array<[string, string]>, yy: number, opts: { x?: number; w?: number; labelW?: number; rowH?: number } = {} ): number => { const x = opts.x ?? MARGIN; const w = opts.w ?? CONTENT_W; const labelW = opts.labelW ?? 210; const rowH = opts.rowH ?? 19; let cy = yy; for (const [k, v] of rows) { doc.font("sans").fontSize(9).fillColor(INK_SOFT).text(k, x, cy + 4, { width: labelW, characterSpacing: 0.4, }); doc.font("sansBold").fontSize(9).fillColor(INK).text(v, x + labelW, cy + 4, { width: w - labelW, align: "right", }); rule(x, cy + rowH, x + w); cy += rowH; } return cy; }; // ---------- STRONA 1 ---------- doc.font("display").fontSize(15).fillColor(INK).text("VILMAX", MARGIN, y, { characterSpacing: 4, }); doc.font("sans").fontSize(8).fillColor(INK_FAINT).text("KARTA PRODUKTU", 0, y + 6, { width: PAGE_W - MARGIN, align: "right", characterSpacing: 2.2, }); y += 24; rule(MARGIN, y, PAGE_W - MARGIN, INK, 1.1); y += 14; doc.font("display").fontSize(24).fillColor(INK).text(data.product.name, MARGIN, y, { width: CONTENT_W, }); y += 34; doc .font("sans") .fontSize(9.5) .fillColor(INK_SOFT) .text(`${kindPl} · ${data.fabricLabel}`, MARGIN, y, { width: 300 }); doc .font("sans") .fontSize(9.5) .fillColor(INK_SOFT) .text( `${data.identifiers.find(([k]) => k.startsWith("SKU"))?.[1] ?? ""} · ${dateFmt(data.generatedAt)}`, MARGIN, y, { width: CONTENT_W, align: "right" } ); y += 20; // zdjęcie główne — zatwierdzone, normalizowane do JPEG sRGB if (data.hero) { const heroH = 300; const buf = await toJpeg(data.hero.absPath, CONTENT_W, heroH); doc.rect(MARGIN, y, CONTENT_W, heroH).fill("#ffffff"); doc.image(buf, MARGIN, y, { fit: [CONTENT_W, heroH], align: "center", valign: "center", }); doc.rect(MARGIN, y, CONTENT_W, heroH).lineWidth(0.7).strokeColor(LINE).stroke(); y += heroH + 26; } if (data.params.length) { sectionTitle(y, "KLUCZOWE PARAMETRY"); y += 16; y = paramTable(data.params, y, { rowH: 20 }); y += 20; } if (data.packageLines.length || data.assemblyLine) { sectionTitle(y, "ZESTAW I MONTAŻ"); y += 16; doc.font("sans").fontSize(9).fillColor(INK); for (const line of data.packageLines) { doc.text(`— ${line}`, MARGIN, y, { width: CONTENT_W }); y += 15; } if (data.assemblyLine) { doc.text(`— ${data.assemblyLine}`, MARGIN, y, { width: CONTENT_W }); y += 15; } } // ---------- STRONA 2 ---------- doc.addPage(); y = MARGIN; doc.font("sansBold").fontSize(8).fillColor(INK_FAINT).text( `${data.product.name.toUpperCase()} — KARTA PRODUKTU`, MARGIN, y, { characterSpacing: 1.4 } ); y += 14; rule(MARGIN, y, PAGE_W - MARGIN, INK, 1.1); y += 14; if (data.blueprint) { sectionTitle(y, "RYSUNEK WYMIAROWY"); y += 15; const bpH = Math.min(290, CONTENT_W * 0.719); // blueprint 1600×1150 const buf = await toJpeg(data.blueprint.absPath, CONTENT_W, bpH); doc.image(buf, MARGIN, y, { fit: [CONTENT_W, bpH], align: "center", valign: "center" }); doc.rect(MARGIN, y, CONTENT_W, bpH).lineWidth(0.7).strokeColor(LINE).stroke(); y += bpH + 18; } if (data.gallery.length) { sectionTitle(y, "GALERIA"); y += 15; const cols = 4; const gap = 8; const cellW = (CONTENT_W - gap * (cols - 1)) / cols; const cellImgH = 86; const cellH = cellImgH + 15; for (let i = 0; i < data.gallery.length; i++) { const col = i % cols; if (col === 0 && y + cellH > PAGE_H - 60) { doc.addPage(); y = MARGIN; } const g = data.gallery[i]!; const cx = MARGIN + col * (cellW + gap); const buf = await toJpeg(g.absPath, cellW, cellImgH); doc.image(buf, cx, y, { fit: [cellW, cellImgH], align: "center", valign: "center" }); doc.rect(cx, y, cellW, cellImgH).lineWidth(0.7).strokeColor(LINE).stroke(); doc .font("sans") .fontSize(7) .fillColor(INK_SOFT) .text(g.label, cx, y + cellImgH + 3, { width: cellW, characterSpacing: 0.3, }); if (col === cols - 1 || i === data.gallery.length - 1) y += cellH + gap; } y += 12; } // Tkanina (lewa kolumna) + identyfikatory (prawa kolumna) — dwie sekcje obok siebie. const hasFabric = Boolean(data.fabricHeading && data.fabricLines.length); if (hasFabric || data.identifiers.length) { if (y + 100 > PAGE_H - 60) { doc.addPage(); y = MARGIN; } const colGap = 24; const colW = (CONTENT_W - colGap) / 2; const leftX = MARGIN; const rightX = MARGIN + colW + colGap; let yEnd = y; if (data.identifiers.length) { doc .font("sansBold") .fontSize(10) .fillColor(ACCENT) .text("IDENTYFIKATORY I GWARANCJA", rightX, y, { width: colW, characterSpacing: 1.6 }); yEnd = Math.max( yEnd, paramTable(data.identifiers, y + 16, { x: rightX, w: colW, labelW: 118, rowH: 19 }) ); } if (hasFabric) { doc .font("sansBold") .fontSize(10) .fillColor(ACCENT) .text(`TKANINA — ${data.fabricHeading!.toUpperCase()}`, leftX, y, { width: colW, characterSpacing: 1.6, }); let fy = y + 17; for (const line of data.fabricLines) { doc.font("sans").fontSize(8).fillColor(INK); doc.text(`— ${line}`, leftX, fy, { width: colW }); fy += doc.heightOfString(`— ${line}`, { width: colW }) + 4; } yEnd = Math.max(yEnd, fy); } y = yEnd; } // ---------- STRONA WARIANTÓW: próbka + miniatury wersji L/P ---------- // kompaktowa tabela — wszystkie odcienie na jednej stronie if (data.variants.length) { doc.addPage(); y = MARGIN; doc.font("sansBold").fontSize(8).fillColor(INK_FAINT).text( `${data.product.name.toUpperCase()} — KARTA PRODUKTU`, MARGIN, y, { characterSpacing: 1.4 } ); y += 14; rule(MARGIN, y, PAGE_W - MARGIN, INK, 1.1); y += 14; sectionTitle(y, `WARIANTY${data.fabricHeading ? ` — ${data.fabricHeading.toUpperCase()}` : ""}`); y += 15; doc .font("sans") .fontSize(8) .fillColor(INK_SOFT) .text( "Kolorystyka na wydruku ma charakter poglądowy — wiążący jest fizyczny próbnik tkaniny.", MARGIN, y, { width: CONTENT_W } ); y += 18; // nagłówek kolumn const swW = 40; const nameW = 210; const thW = 74; const xSw = MARGIN; const xName = xSw + swW + 12; const sideCols = data.cardSides.map((s, i) => ({ side: s, x: xName + nameW + 10 + i * (thW + 14), label: s === "L" ? "WERSJA LEWA" : s === "P" ? "WERSJA PRAWA" : "WERSJA", })); doc.font("sansBold").fontSize(7.5).fillColor(INK_FAINT); doc.text("PRÓBKA", xSw, y, { width: swW }); doc.text("ODCIEŃ", xName, y, { width: nameW }); for (const c of sideCols) doc.text(c.label, c.x, y, { width: thW, align: "center" }); y += 12; rule(MARGIN, y, PAGE_W - MARGIN, LINE, 0.7); y += 4; const rowH = 34; const thumbH = 28; for (const vr of data.variants) { if (y + rowH > PAGE_H - 60) { doc.addPage(); y = MARGIN; } const cy = y + (rowH - thumbH) / 2; // próbka: oficjalny obraz Davis, fallback — chip color_hex if (vr.swatchAbs && fs.existsSync(vr.swatchAbs)) { const sw = await toJpeg(vr.swatchAbs, swW, thumbH); doc.image(sw, xSw, cy, { fit: [swW, thumbH], align: "center", valign: "center" }); } else { doc.rect(xSw, cy, swW, thumbH).fill(vr.colorHex ?? "#cccccc"); } doc.rect(xSw, cy, swW, thumbH).lineWidth(0.7).strokeColor(LINE).stroke(); // nazwa odcienia doc .font("sansBold") .fontSize(9) .fillColor(INK) .text(`${data.fabricLabel.split(" ")[0] ?? ""} ${vr.code}`, xName, y + 4, { width: nameW }); if (vr.name) { doc.font("sans").fontSize(8).fillColor(INK_SOFT).text(vr.name, xName, y + 17, { width: nameW }); } // miniatury per strona wariantu for (const { side, x: xx } of sideCols) { const th = vr.thumbs[side]; if (th && fs.existsSync(th.absPath)) { const buf = await toJpeg(th.absPath, thW, thumbH); doc.image(buf, xx, cy, { fit: [thW, thumbH], align: "center", valign: "center" }); } else { doc.rect(xx, cy, thW, thumbH).lineWidth(0.7).strokeColor(LINE).stroke(); doc .font("sans") .fontSize(7) .fillColor(INK_FAINT) .text("—", xx, cy + thumbH / 2 - 4, { width: thW, align: "center" }); } doc.rect(xx, cy, thW, thumbH).lineWidth(0.7).strokeColor(LINE).stroke(); } y += rowH; rule(MARGIN, y, PAGE_W - MARGIN, LINE, 0.5); y += 2; } } const range = doc.bufferedPageRange(); for (let i = range.start; i < range.start + range.count; i++) { doc.switchToPage(i); stampFooter(i + 1, range.count); } doc.end(); return done; } /** * Buduje kartę, zapisuje plik i rejestruje asset (kind pdf, role document). * Zwraca { missing } zamiast tworzyć dokument, gdy brakuje zatwierdzeń. */ export async function buildProductCard( db: Db, cfg: AppConfig, productId: string, execution?: { jobId: string; beforePersist: () => void } ): Promise<{ asset: AssetRow } | { missing: string[] }> { const data = collectCardData(db, productId, cfg.mediaDir); const variantImgs = data.variants.flatMap((v) => data.cardSides.map((s) => v.thumbs[s])).filter(Boolean); for (const img of [data.hero, data.blueprint, ...data.gallery, ...variantImgs]) { if (img && !fs.existsSync(img.absPath)) { data.missing.push(`plik na dysku: ${img.role} (${img.assetId})`); } } if (data.missing.length) return { missing: data.missing }; const buf = await renderProductCardPdf(data); execution?.beforePersist(); const sha = crypto.createHash("sha256").update(buf).digest("hex"); const rel = path.join("assets", sha.slice(0, 2), `${sha}.pdf`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, buf, { flag: "wx" }); const asset = registerAsset(db, { productId, ...(execution ? { jobId: execution.jobId } : {}), kind: "pdf", role: "document", sha256: sha, mime: "application/pdf", bytes: buf.length, path: rel, origin: "derived", provenance: { document: "product-card", version: data.version, generatedAt: data.generatedAt, factsSnapshot: data.factsSnapshot, images: [data.hero, ...data.gallery].filter(Boolean).map((g) => ({ role: g!.role, assetId: g!.assetId, sha256: g!.sha256, version: g!.version, })), blueprint: data.blueprint ? { assetId: data.blueprint.assetId, sha256: data.blueprint.sha256, version: data.blueprint.version } : null, blueprintAiDrawn: data.blueprintAiDrawn, variants: data.variants.map((v) => ({ shade: v.code, thumbs: Object.fromEntries( data.cardSides.map((s) => [s, v.thumbs[s] ? { assetId: v.thumbs[s]!.assetId, sha256: v.thumbs[s]!.sha256 } : null]) ), })), }, note: `Karta produktu A4 · v${data.version} · fakty confirmed + zatwierdzone ujęcia + blueprint.`, }); return { asset }; }