Files
fourge-portal/src/lib/brandBookEditor.js
T
Krao Hasanee eee0885811 Fix file sharing load speed and move error; misc updates
- Remove recursive directory size calculations (single Seafile API call per list)
- Remove 'Used in this location' usage display
- Fix move using v2 per-type endpoints instead of broken batch endpoint
- Send entry type from frontend for correct move routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:20:38 -04:00

1203 lines
46 KiB
JavaScript
Raw 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 jsPDF from 'jspdf';
// Letter landscape: 792 x 612 pt
const W = 792;
const H = 612;
const MARGIN = 36;
const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18];
const HEADER_H = 64;
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
function formatCoverAddress(address) {
const parts = String(address || '')
.split(',')
.map(part => part.trim())
.filter(Boolean);
if (parts.length >= 3) {
const countryless = parts[parts.length - 1].toLowerCase() === 'united states' || parts[parts.length - 1].toLowerCase() === 'usa'
? parts.slice(0, -1)
: parts;
return [
countryless[0],
countryless.slice(1).join(', '),
].filter(Boolean);
}
return address ? [address] : [];
}
// Parse JPEG EXIF orientation tag from raw bytes (returns 18; 1 = normal).
function parseExifOrientation(bytes) {
if (bytes.length < 4 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return 1;
let pos = 2;
while (pos < bytes.length - 4) {
if (bytes[pos] !== 0xFF) break;
const marker = bytes[pos + 1];
if (marker === 0xDA) break; // SOS — no more metadata
const segLen = (bytes[pos + 2] << 8) | bytes[pos + 3];
if (marker === 0xE1 && segLen >= 14 &&
bytes[pos+4]===0x45 && bytes[pos+5]===0x78 && bytes[pos+6]===0x69 &&
bytes[pos+7]===0x66 && bytes[pos+8]===0x00 && bytes[pos+9]===0x00) {
const tiff = pos + 10;
const le = bytes[tiff] === 0x49;
const r16 = (o) => le ? (bytes[o]|(bytes[o+1]<<8)) : ((bytes[o]<<8)|bytes[o+1]);
const r32 = (o) => le
? ((bytes[o]|(bytes[o+1]<<8)|(bytes[o+2]<<16)|(bytes[o+3]<<24))>>>0)
: (((bytes[o]<<24)|(bytes[o+1]<<16)|(bytes[o+2]<<8)|bytes[o+3])>>>0);
const ifd = tiff + r32(tiff + 4);
if (ifd + 2 > bytes.length) break;
const n = r16(ifd);
for (let i = 0; i < n; i++) {
const e = ifd + 2 + i * 12;
if (e + 12 > bytes.length) break;
if (r16(e) === 0x0112) return r16(e + 8);
}
}
pos += 2 + segLen;
}
return 1;
}
// Apply EXIF orientation correction to a Blob → returns corrected data URL.
//
// Strategy: try createImageBitmap(imageOrientation:'none') first to get raw pixels,
// then apply explicit canvas transforms. BUT some browsers (older Safari) ignore the
// option and return already-corrected pixels — detect this by comparing bitmap
// dimensions to what the EXIF orientation implies for raw pixels, and skip the
// transform if the browser already corrected. Falls back to img→canvas if
// createImageBitmap throws.
async function correctOrientation(blob) {
if (!blob) return null;
const isPng = blob.type === 'image/png';
const toDataUrl = (b) => new Promise((res) => {
const r = new FileReader();
r.onload = (e) => res(e.target.result);
r.onerror = () => res(null);
r.readAsDataURL(b);
});
if (isPng) return toDataUrl(blob);
let orientation = 1;
let ab = null;
try { ab = await blob.arrayBuffer(); orientation = parseExifOrientation(new Uint8Array(ab)); }
catch { /* treat as normal */ }
if (orientation <= 1) return toDataUrl(blob);
// Helper: build canvas with explicit transform if needed.
// swapAxes orientations (5-8): raw JPEG stores landscape pixels.
// If browser ignored imageOrientation:'none', it returned corrected (portrait) pixels —
// detect this by checking bmpW > bmpH (raw=landscape) vs bmpW < bmpH (corrected=portrait).
const applyToCanvas = (source, bmpW, bmpH) => {
const swapAxes = orientation >= 5;
// For swap-axes orientations: raw = landscape (w > h), corrected = portrait (w < h)
const isRaw = swapAxes ? (bmpW > bmpH) : true;
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (isRaw && swapAxes) { c.width = bmpH; c.height = bmpW; }
else { c.width = bmpW; c.height = bmpH; }
if (isRaw) {
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, bmpW, 0); break;
case 3: ctx.transform(-1, 0, 0, -1, bmpW, bmpH); break;
case 4: ctx.transform( 1, 0, 0, -1, 0, bmpH); break;
case 5: ctx.transform( 0, 1, 1, 0, 0, 0); break;
case 6: ctx.transform( 0, 1, -1, 0, bmpH, 0); break;
case 7: ctx.transform( 0, -1, -1, 0, bmpH, bmpW); break;
case 8: ctx.transform( 0, -1, 1, 0, 0, bmpW); break;
}
}
ctx.drawImage(source, 0, 0);
return c.toDataURL('image/jpeg', 0.92);
};
// Primary: createImageBitmap with imageOrientation:'none'
try {
const src = ab ? new Blob([ab], { type: blob.type }) : blob;
const bmp = await createImageBitmap(src, { imageOrientation: 'none' });
const result = applyToCanvas(bmp, bmp.width, bmp.height);
bmp.close();
return result;
} catch { /* fall through */ }
// Fallback: img→canvas (browser may auto-correct EXIF — dimension check handles both cases)
const dataUrl = await toDataUrl(blob);
if (!dataUrl) return null;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try { resolve(applyToCanvas(img, img.naturalWidth, img.naturalHeight)); }
catch { resolve(dataUrl); }
};
img.onerror = () => resolve(dataUrl);
img.src = dataUrl;
});
}
function getDataUrlMimeType(dataUrl) {
if (typeof dataUrl !== 'string') return '';
const match = dataUrl.match(/^data:([^;,]+)[;,]/i);
return match?.[1]?.toLowerCase() || '';
}
function isPdfSafeDataUrl(dataUrl) {
const mime = getDataUrlMimeType(dataUrl);
return mime === 'image/png' || mime === 'image/jpeg';
}
function getJsPdfFormat(dataUrl) {
return getDataUrlMimeType(dataUrl) === 'image/png' ? 'PNG' : 'JPEG';
}
async function ensurePdfSafeDataUrl(dataUrl, preferPng = false) {
if (!dataUrl) return null;
if (isPdfSafeDataUrl(dataUrl)) return dataUrl;
const img = await loadImage(dataUrl);
if (!img) return null;
const c = document.createElement('canvas');
c.width = img.naturalWidth || img.width || 1;
c.height = img.naturalHeight || img.height || 1;
const ctx = c.getContext('2d');
if (!ctx) return null;
if (!preferPng) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, c.width, c.height);
}
ctx.drawImage(img, 0, 0);
return preferPng
? c.toDataURL('image/png')
: c.toDataURL('image/jpeg', 0.92);
}
function addDataUrlImage(doc, dataUrl, x, y, w, h) {
if (!dataUrl) return;
doc.addImage(dataUrl, getJsPdfFormat(dataUrl), x, y, w, h);
}
// Accepts File/Blob, data URL string, or https/blob URL string.
// Returns EXIF-corrected, PDF-safe data URL, or null on failure.
// 15-second fetch timeout prevents infinite hangs on stalled network requests.
async function resolvePhoto(source) {
if (!source) return null;
if (source instanceof File || source instanceof Blob) {
const corrected = await correctOrientation(source);
return ensurePdfSafeDataUrl(corrected, source.type === 'image/png');
}
if (typeof source === 'string') {
if (source.startsWith('data:')) {
return ensurePdfSafeDataUrl(source, getDataUrlMimeType(source) === 'image/png');
}
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
const resp = await fetch(source, { signal: ctrl.signal });
clearTimeout(timer);
const blob = await resp.blob();
const corrected = await correctOrientation(blob);
return ensurePdfSafeDataUrl(corrected, blob.type === 'image/png');
} catch { return null; }
}
return null;
}
// Downsample a data-URL image to 300 DPI for its rendered display size before
// embedding in jsPDF. PNGs are preserved only when needed for transparency.
// Images already at or below the target pixel count are returned as-is.
const PX_PER_PT = 300 / 72;
const MAP_PX_PER_PT = 180 / 72;
const SIGN_PX_PER_PT = 180 / 72;
const THUMB_PX_PER_PT = 110 / 72;
function toMapJpeg(dataUrl, img, displayPtW, displayPtH, quality = 0.78) {
if (!dataUrl || !img) return dataUrl;
const tW = Math.max(1, Math.round(displayPtW * MAP_PX_PER_PT));
const tH = Math.max(1, Math.round(displayPtH * MAP_PX_PER_PT));
if (!dataUrl.startsWith('data:image/png') && img.naturalWidth <= tW && img.naturalHeight <= tH) return dataUrl;
const c = document.createElement('canvas');
c.width = tW;
c.height = tH;
c.getContext('2d').drawImage(img, 0, 0, tW, tH);
return c.toDataURL('image/jpeg', quality);
}
function toCoverImage(dataUrl, img, displayPtW, displayPtH, quality = 0.82, pxPerPt = PX_PER_PT) {
if (!dataUrl || !img) return null;
const tW = Math.max(1, Math.round(displayPtW * pxPerPt));
const tH = Math.max(1, Math.round(displayPtH * pxPerPt));
const srcAspect = img.naturalWidth / img.naturalHeight;
const dstAspect = displayPtW / displayPtH;
let sx = 0;
let sy = 0;
let sW = img.naturalWidth;
let sH = img.naturalHeight;
if (srcAspect > dstAspect) {
sW = img.naturalHeight * dstAspect;
sx = (img.naturalWidth - sW) / 2;
} else {
sH = img.naturalWidth / dstAspect;
sy = (img.naturalHeight - sH) / 2;
}
const c = document.createElement('canvas');
c.width = tW;
c.height = tH;
const ctx = c.getContext('2d');
ctx.drawImage(img, sx, sy, sW, sH, 0, 0, tW, tH);
const format = dataUrl.startsWith('data:image/png') ? 'PNG' : 'JPEG';
const croppedDataUrl = format === 'PNG'
? c.toDataURL('image/png')
: c.toDataURL('image/jpeg', quality);
return { dataUrl: croppedDataUrl, format };
}
function loadImage(src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
function addHeader(doc, logo, logoW, logoH, title, pageNum, totalPages) {
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, HEADER_H, 'F');
doc.setFillColor(...ACCENT);
doc.rect(0, 0, 4, HEADER_H, 'F');
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.3);
doc.line(0, HEADER_H, W, HEADER_H);
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH);
} else {
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3);
}
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(80, 80, 80);
doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' });
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' });
}
function addFooter(doc, clientName, date) {
doc.setDrawColor(210, 210, 210);
doc.setLineWidth(0.3);
doc.line(MARGIN, H - 22, W - MARGIN, H - 22);
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(clientName, MARGIN, H - 12);
doc.text('hello@fourgebranding.com · www.fourgebranding.com', W / 2, H - 12, { align: 'center' });
if (date) doc.text(date, W - MARGIN, H - 12, { align: 'right' });
}
const BOLCHOZ_LOGO_H = 36; // 0.5" client logo height
const BOLCHOZ_FOOTER_H = BOLCHOZ_LOGO_H + 8; // space reserved at bottom for footer
function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg) {
const logoY = H - MARGIN - BOLCHOZ_LOGO_H;
let footerLogoW = 0;
if (clientLogoDataUrl && clientLogoImg) {
const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight;
footerLogoW = BOLCHOZ_LOGO_H * aspect;
addDataUrlImage(doc, clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
}
const pgLabel = `Page ${String(pageNum).padStart(2, '0')} of ${String(totalPages).padStart(2, '0')}`;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(pgLabel, W - MARGIN, H - MARGIN, { align: 'right' });
}
export async function generateBrandBookEditorPDF(data) {
const { template = 'fourge', clientName, projectName, siteAddress, bookDate, preparedBy, revision,
siteMapSource, inventoryMapSource, signs, sitePhotoSources,
projectLogoSource, creationDate, revisionDate,
customerName, customerAddress, clientLogoSource,
clientContactName, clientContactEmail, clientContactPhone,
approvedDate, approvalNotes } = data;
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt', compress: true });
// Load assets
const logo = await loadImage('/fourge-logo.png');
const logoW = 40;
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 10;
// Resolve all photos to data URLs up front
const [siteMapDataUrl, inventoryMapDataUrl, projectLogoDataUrl, clientLogoDataUrl] = await Promise.all([
resolvePhoto(siteMapSource),
resolvePhoto(inventoryMapSource),
resolvePhoto(projectLogoSource),
resolvePhoto(clientLogoSource),
]);
const clientLogoImg = clientLogoDataUrl ? await loadImage(clientLogoDataUrl) : null;
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
const signExistingPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.existingPhotoSource)));
const signRecommendationPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.recommendationPhotoSource)));
const signDetailPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.signDetailPhotoSource)));
const sitePhotoDataUrls = await Promise.all((sitePhotoSources || []).map(s => resolvePhoto(s)));
const validSitePhotos = sitePhotoDataUrls.filter(Boolean);
const PHOTOS_PER_PAGE = 16;
// Count pages
const INV_LABEL_H = 18;
const TABLE_ROW_H = 18;
const invFirstH = H - MARGIN - BOLCHOZ_FOOTER_H - (MARGIN + INV_LABEL_H);
const invFirstCap = Math.max(0, Math.floor(invFirstH / TABLE_ROW_H) - 1);
const invContH = invFirstH; // same geometry on cont. pages
const invContCap = Math.max(1, Math.floor(invContH / TABLE_ROW_H) - 1);
const extraInvPages = template === 'bolchoz' && signs.length > invFirstCap
? Math.ceil((signs.length - invFirstCap) / invContCap)
: 0;
let totalPages = 1; // cover
if (siteMapDataUrl) totalPages++; // site map page
totalPages++; // sign inventory
totalPages += extraInvPages; // overflow inventory pages
totalPages += signs.length; // sign pages
totalPages += Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE); // site photo pages
let pageNum = 0;
const rev = String(revision || '01').padStart(2, '0');
const displayDate = bookDate
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: '';
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
pageNum++;
if (template === 'bolchoz') {
// ── Bolchoz Sign Solutions Cover ─────────────────────────────────────────────
// White background
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, H, 'F');
// ── Project logo box (top left, 5"×5" = 360×360pt, no border) ──────────────
const logoBoxSize = 288; // 4"
const logoBoxX = MARGIN;
const logoBoxY = MARGIN; // flush to top margin
if (projectLogoDataUrl) {
const pImg = await loadImage(projectLogoDataUrl);
if (pImg) {
const ratio = pImg.naturalWidth / pImg.naturalHeight;
let dW, dH;
if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; }
else { dH = logoBoxSize; dW = dH * ratio; }
addDataUrlImage(doc, projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
}
} else {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(210, 210, 210);
doc.text('PROJECT LOGO', logoBoxX + logoBoxSize / 2, logoBoxY + logoBoxSize / 2, { align: 'center' });
}
// ── Fourge logo (left of right column) ───────────────────────────────────────
const dateColX = logoBoxX + logoBoxSize + 20;
if (logo) {
doc.addImage(logo, 'PNG', dateColX, logoBoxY, logoW, logoH);
} else {
doc.setFontSize(8); doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text('FOURGE BRANDING', dateColX, logoBoxY + 8);
}
// ── Dates (top right corner, right-aligned, starting at same Y as logo top) ──
// Helper: right-align text at W-MARGIN, accounting for charSpace
const rtx = (text, cs = 0) => {
const w = doc.getTextWidth(text) + (text.length > 1 ? (text.length - 1) * cs : 0);
return W - MARGIN - w;
};
let ty = logoBoxY;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('CREATION DATE', rtx('CREATION DATE', 1.5), ty);
doc.setCharSpace(0);
ty += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
if (creationDate) doc.text(formatDate(creationDate), rtx(formatDate(creationDate)), ty);
ty += 28;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('REVISION DATE', rtx('REVISION DATE', 1.5), ty);
doc.setCharSpace(0);
ty += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
if (revisionDate) doc.text(formatDate(revisionDate), rtx(formatDate(revisionDate)), ty);
const halfW = (W - MARGIN * 2 - 20) / 2;
// ── Bottom left: Customer (anchored to bottom-left corner) ──────────────────
const addrText = customerAddress || siteAddress || '';
const addrLineH = 11;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
const addrLines = formatCoverAddress(addrText);
// Work bottom-up to anchor to H - MARGIN
const lY_addr = H - MARGIN;
const lY_addrStart = addrLines.length > 0 ? lY_addr - (addrLines.length - 1) * addrLineH : lY_addr;
const lY_addrLabel = (addrLines.length > 0 ? lY_addrStart : lY_addr) - 16;
const lY_name = lY_addrLabel - 28;
const lY_customerLabel = lY_name - 16;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('CUSTOMER', MARGIN, lY_customerLabel);
doc.setCharSpace(0);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(20, 20, 20);
if (customerName || clientName) doc.text(customerName || clientName, MARGIN, lY_name);
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('ADDRESS', MARGIN, lY_addrLabel);
doc.setCharSpace(0);
if (addrLines.length > 0) {
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
doc.text(addrLines, MARGIN, lY_addrStart);
}
// ── Right column: Signature / Approval (under revision date, right-aligned) ──
const sigLineH = 14; // ~1 line break
let rY = ty + sigLineH * 8; // 8 line breaks below revision date
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('SIGNATURE OF APPROVAL', rtx('SIGNATURE OF APPROVAL', 1.5), rY);
doc.setCharSpace(0);
rY += 16 + 12 + 28; // same spacing as if value were present
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('APPROVED DATE', rtx('APPROVED DATE', 1.5), rY);
doc.setCharSpace(0);
rY += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
if (approvedDate) doc.text(formatDate(approvedDate), rtx(formatDate(approvedDate)), rY);
rY += 28;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('NOTES', rtx('NOTES', 1.5), rY);
doc.setCharSpace(0);
rY += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
if (approvalNotes) {
const noteLines = doc.splitTextToSize(approvalNotes, halfW);
doc.text(noteLines, W - MARGIN, rY, { align: 'right' });
}
// ── Client logo + contact (right-bottom aligned) ──────────────────────────────
const clientLogoW = 252; // 3.5"
const clientLogoH = 108; // 1.5"
const contactLineH = 14;
const contactLines = [clientContactName, clientContactEmail, clientContactPhone].filter(Boolean);
const contactBlockH = contactLines.length * contactLineH;
// Contact text right-aligned at bottom
const contactStartY = H - MARGIN - contactBlockH + 10;
if (contactLines.length > 0) {
let cy2 = contactStartY;
if (clientContactName) {
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(40, 40, 40);
doc.text(clientContactName, W - MARGIN, cy2, { align: 'right' });
cy2 += contactLineH;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
if (clientContactEmail) { doc.text(clientContactEmail, W - MARGIN, cy2, { align: 'right' }); cy2 += contactLineH; }
if (clientContactPhone) { doc.text(clientContactPhone, W - MARGIN, cy2, { align: 'right' }); }
}
// Client logo box above contact text
const clBoxBottom = H - MARGIN - (contactBlockH > 0 ? contactBlockH + 8 : 0);
const clBoxTop = clBoxBottom - clientLogoH;
const clBoxLeft = W - MARGIN - clientLogoW;
if (clientLogoDataUrl) {
const clImg = await loadImage(clientLogoDataUrl);
if (clImg) {
const ratio = clImg.naturalWidth / clImg.naturalHeight;
const boxRatio = clientLogoW / clientLogoH;
let dW, dH, dx, dy;
if (ratio > boxRatio) {
dW = clientLogoW; dH = dW / ratio;
dx = clBoxLeft; dy = clBoxTop + (clientLogoH - dH) / 2;
} else {
dH = clientLogoH; dW = dH * ratio;
dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop;
}
addDataUrlImage(doc, clientLogoDataUrl, dx, dy, dW, dH);
}
} else {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(210, 210, 210);
doc.text('CLIENT LOGO', clBoxLeft + clientLogoW / 2, clBoxTop + clientLogoH / 2, { align: 'center' });
}
} else {
// ── Fourge Branding Default Cover ────────────────────────────────────────────
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, H, 'F');
doc.setFillColor(...ACCENT);
doc.rect(0, 0, W, 5, 'F');
doc.rect(0, H - 5, W, 5, 'F');
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, 18, logoW, logoH);
} else {
doc.setFontSize(10); doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK); doc.text('FOURGE BRANDING', MARGIN, 30);
}
const cY = H / 2;
if (projectLogoDataUrl) {
const pImg = await loadImage(projectLogoDataUrl);
if (pImg) {
const logoAreaW = 170;
const logoAreaH = 120;
const ratio = pImg.naturalWidth / pImg.naturalHeight;
let dW;
let dH;
if (ratio >= logoAreaW / logoAreaH) {
dW = logoAreaW;
dH = dW / ratio;
} else {
dH = logoAreaH;
dW = dH * ratio;
}
addDataUrlImage(doc, projectLogoDataUrl, W / 2 - dW / 2, cY - 190, dW, dH);
}
}
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT); doc.setCharSpace(3);
doc.text('BRAND BOOK', W / 2, cY - 50, { align: 'center' });
doc.setCharSpace(0);
doc.setDrawColor(...ACCENT); doc.setLineWidth(0.5);
doc.line(W / 2 - 40, cY - 42, W / 2 + 40, cY - 42);
doc.setFontSize(34); doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text(customerName || clientName || '-', W / 2, cY - 14, { align: 'center' });
if (projectName) {
doc.setFontSize(14); doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 100, 100);
doc.text(projectName, W / 2, cY + 14, { align: 'center' });
}
const coverAddressLines = formatCoverAddress(customerAddress || siteAddress || '');
if (coverAddressLines.length > 0) {
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(coverAddressLines, W / 2, cY + 36, { align: 'center' });
}
const metaParts = [];
if (displayDate) metaParts.push(displayDate);
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
if (metaParts.length > 0) {
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(metaParts.join(' · '), W / 2, cY + 36 + (coverAddressLines.length * 10), { align: 'center' });
}
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.setTextColor(180, 180, 180);
doc.text(`R${rev}`, W - MARGIN, cY + 36, { align: 'right' });
} // end template branch
// ─── SITE MAP PAGE (optional) ─────────────────────────────────────────────────
if (siteMapDataUrl) {
pageNum++;
doc.addPage();
if (template === 'bolchoz') {
const smLabelH = 18; // space below label before map
const smTop = MARGIN + smLabelH;
const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H;
const smW = W - MARGIN * 2;
const smH = smBottom - smTop;
const smImg = await loadImage(siteMapDataUrl);
if (smImg) {
const aspect = smImg.naturalWidth / smImg.naturalHeight;
const boxAspect = smW / smH;
let dw, dh, dx, dy;
if (aspect > boxAspect) {
dh = smH; dw = dh * aspect;
dx = MARGIN - (dw - smW) / 2; dy = smTop;
} else {
dw = smW; dh = dw / aspect;
dx = MARGIN; dy = smTop - (dh - smH) / 2;
}
addDataUrlImage(doc, toMapJpeg(siteMapDataUrl, smImg, dw, dh), dx, dy, dw, dh);
// Mask overflow with white overlays
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, smTop, 'F');
doc.rect(0, smTop + smH, W, H - smTop - smH, 'F');
doc.rect(0, smTop, MARGIN, smH, 'F');
doc.rect(MARGIN + smW, smTop, W - MARGIN - smW, smH, 'F');
}
// Footer and header drawn last so they sit on top of overlays
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('SITE MAP', MARGIN, MARGIN + 10);
doc.setCharSpace(0);
} else {
addHeader(doc, logo, logoW, logoH, 'Site Map', pageNum, totalPages);
addFooter(doc, clientName, displayDate);
const smTop = HEADER_H + 16;
const smBottom = H - 30;
const smW = W - MARGIN * 2;
const smH = smBottom - smTop;
const smImg = await loadImage(siteMapDataUrl);
if (smImg) {
const aspect = smImg.naturalWidth / smImg.naturalHeight;
const boxAspect = smW / smH;
let dw, dh, dx, dy;
if (aspect > boxAspect) {
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
} else {
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
}
doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4);
doc.rect(MARGIN, smTop, smW, smH);
addDataUrlImage(doc, toMapJpeg(siteMapDataUrl, smImg, dw, dh), dx, dy, dw, dh);
}
}
}
// ─── SIGN INVENTORY PAGE ─────────────────────────────────────────────────────
pageNum++;
doc.addPage();
const invLabelH = 18; // space for "FLOOR PLAN" / "SIGN INVENTORY" labels
const invContentTop = template === 'bolchoz' ? MARGIN + invLabelH : HEADER_H + 16;
const invContentBottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const invContentH = invContentBottom - invContentTop;
const tableW = (W - MARGIN * 2) / 3;
const mapW = W - MARGIN * 2 - tableW - 16;
const mapX = MARGIN;
const mapY = invContentTop;
const tableX = MARGIN + mapW + 16;
if (template === 'bolchoz') {
// White background
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, H, 'F');
// Map at "cover" fill
if (inventoryMapDataUrl) {
const invMapImg = await loadImage(inventoryMapDataUrl);
if (invMapImg) {
const imgAspect = invMapImg.naturalWidth / invMapImg.naturalHeight;
const boxAspect = mapW / invContentH;
let drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawH = invContentH; drawW = invContentH * imgAspect;
drawX = mapX - (drawW - mapW) / 2; drawY = mapY;
} else {
drawW = mapW; drawH = mapW / imgAspect;
drawX = mapX; drawY = mapY - (drawH - invContentH) / 2;
}
addDataUrlImage(doc, toMapJpeg(inventoryMapDataUrl, invMapImg, drawW, drawH), drawX, drawY, drawW, drawH);
}
}
// White overlays to crop image to map box
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, mapY, 'F');
doc.rect(0, mapY + invContentH, W, H - mapY - invContentH, 'F');
doc.rect(0, mapY, mapX, invContentH, 'F');
doc.rect(mapX + mapW, mapY, W - mapX - mapW, invContentH, 'F');
if (!inventoryMapDataUrl) {
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
}
// Draw page labels on top of overlays
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('FLOOR PLAN', MARGIN, MARGIN + 10);
doc.text('SIGN INVENTORY', tableX, MARGIN + 10);
doc.setCharSpace(0);
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else {
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
addFooter(doc, clientName, displayDate);
if (inventoryMapDataUrl) {
const invMapImg = await loadImage(inventoryMapDataUrl);
if (invMapImg) {
const imgAspect = invMapImg.naturalWidth / invMapImg.naturalHeight;
const boxAspect = mapW / invContentH;
let drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = mapW; drawH = mapW / imgAspect;
drawX = mapX; drawY = mapY + (invContentH - drawH) / 2;
} else {
drawH = invContentH; drawW = invContentH * imgAspect;
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
}
addDataUrlImage(doc, toMapJpeg(inventoryMapDataUrl, invMapImg, drawW, drawH), drawX, drawY, drawW, drawH);
}
} else {
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
}
}
let nextSignIdx = drawInventoryTable(doc, signs, tableX, invContentTop, tableW, invContentH, template, 0);
// ─── SIGN INVENTORY CONTINUATION PAGES (bolchoz) ─────────────────────────────
while (template === 'bolchoz' && nextSignIdx < signs.length) {
pageNum++;
doc.addPage();
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
const contTop = MARGIN + INV_LABEL_H;
const contH = H - MARGIN - BOLCHOZ_FOOTER_H - contTop;
const contW = W - MARGIN * 2;
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('SIGN INVENTORY', MARGIN, MARGIN + 10);
doc.setCharSpace(0);
nextSignIdx = drawInventoryTable(doc, signs, MARGIN, contTop, contW, contH, template, nextSignIdx);
}
// ─── Cover-fit helper (maintains aspect ratio, fills box with centered crop) ──
// Images are downsampled to 300 DPI for their rendered display size before
// embedding — keeps file size small while maintaining print quality.
const drawCover = async (dataUrl, bx, by, bw, bh, options = {}) => {
const img = await loadImage(dataUrl);
if (!img) return;
const cropped = toCoverImage(dataUrl, img, bw, bh, options.quality ?? 0.8, options.pxPerPt ?? SIGN_PX_PER_PT);
if (!cropped) return;
doc.setFillColor(245, 245, 245);
doc.rect(bx, by, bw, bh, 'F');
doc.addImage(cropped.dataUrl, cropped.format, bx, by, bw, bh);
};
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
for (let i = 0; i < signs.length; i++) {
const sign = signs[i];
const photoDataUrl = signPhotoDataUrls[i];
pageNum++;
doc.addPage();
const signLabel = `Sign ${sign.signNumber || (i + 1)}${sign.type || 'Sign'}`;
if (template === 'bolchoz') {
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else {
addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages);
addFooter(doc, clientName, displayDate);
}
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
if (template === 'bolchoz') {
// ── Bolchoz sign page ──────────────────────────────────────────────────────
const TEAL = [80, 80, 80]; // dark gray divider
const PHOTO_W = 288; // 4"
const PHOTO_H = 180; // 2.5"
const photoX = W - MARGIN - PHOTO_W;
const textW = photoX - MARGIN - 16;
// Diamond header
const dr = 20;
const dcx = MARGIN + dr;
const dcy = top + dr;
doc.setDrawColor(160, 160, 160);
doc.setLineWidth(0.8);
doc.lines([[dr, dr], [-dr, dr], [-dr, -dr], [dr, -dr]], dcx, dcy - dr, [1, 1], 'S', true);
doc.setFontSize(13);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text(sign.signNumber || String(i + 1), dcx, dcy + 4, { align: 'center' });
const recText = sign.recommendation || '-';
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text(doc.splitTextToSize(recText, W - MARGIN - (dcx + dr + 12))[0], dcx + dr + 12, dcy + 5);
doc.setCharSpace(0);
const tealY = top + dr * 2 + 8;
doc.setDrawColor(...TEAL);
doc.setLineWidth(1);
doc.line(MARGIN, tealY, W - MARGIN, tealY);
const sy = tealY + 16;
// ── Two-column layout ──────────────────────────────────────────────────────
const colStart = sy;
// ── Compute all positions ─────────────────────────────────────────────────
const rcAvailH = bottom - colStart;
const RC_LABEL_H = 16;
const RC_PHOTO_H = (rcAvailH - RC_LABEL_H * 2) / 2;
const existHeaderY = colStart;
const existPhotoY = existHeaderY + RC_LABEL_H;
const recHeaderY = existPhotoY + RC_PHOTO_H;
const recPhotoY = recHeaderY + RC_LABEL_H;
const availH = bottom - colStart;
const LABEL_H = 16;
const DETAIL_H = 180; // 2.5"
const GAP = 16;
const specH = availH / 3;
const specStartY = colStart;
const specContentY = specStartY + LABEL_H;
const detailHeaderY = colStart + specH + GAP;
const detailPhotoY = detailHeaderY + LABEL_H;
const notesStartY = detailPhotoY + DETAIL_H + GAP;
const notesContentY = notesStartY + LABEL_H;
const specLines = sign.specifications ? doc.splitTextToSize(sign.specifications, textW) : [];
const noteLines = sign.notes ? doc.splitTextToSize(sign.notes, textW) : [];
// ── Draw order: sign detail first, right column photos second (covers any
// spill from sign detail masks), teal line last before text ──────────────
await drawCover(signDetailPhotoDataUrls[i], MARGIN, detailPhotoY, textW, DETAIL_H);
await drawCover(signExistingPhotoDataUrls[i], photoX, existPhotoY, PHOTO_W, RC_PHOTO_H);
await drawCover(signRecommendationPhotoDataUrls[i], photoX, recPhotoY, PHOTO_W, RC_PHOTO_H);
// Redraw teal line on top of any photo masks
doc.setDrawColor(...TEAL); doc.setLineWidth(1);
doc.line(MARGIN, tealY, W - MARGIN, tealY);
// ── All text last so nothing gets painted over ────────────────────────────
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(80, 80, 80);
doc.text('Existing:', photoX, existHeaderY + 12);
if (sign.type) {
const lw = doc.getTextWidth('Existing:');
doc.setFont('helvetica', 'normal'); doc.setTextColor(50, 50, 50);
doc.text(doc.splitTextToSize(sign.type, PHOTO_W - lw - 4)[0], photoX + lw + 4, existHeaderY + 12);
}
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(80, 80, 80);
doc.text('Recommendation:', photoX, recHeaderY + 12);
if (sign.recommendation) {
const lw = doc.getTextWidth('Recommendation:');
doc.setFont('helvetica', 'normal'); doc.setTextColor(50, 50, 50);
doc.text(doc.splitTextToSize(sign.recommendation, PHOTO_W - lw - 4)[0], photoX + lw + 4, recHeaderY + 12);
}
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(100, 100, 100);
doc.text('Specifications:', MARGIN, specStartY + 11);
if (specLines.length) {
doc.setFont('helvetica', 'normal'); doc.setTextColor(60, 60, 60);
doc.text(specLines, MARGIN, specContentY + 11);
}
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(120, 120, 120);
doc.text('Sign Detail:', MARGIN, detailHeaderY + 11);
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(100, 100, 100);
doc.text('Notes:', MARGIN, notesStartY + 11);
if (noteLines.length) {
doc.setFont('helvetica', 'italic'); doc.setTextColor(80, 80, 80);
doc.text(noteLines, MARGIN, notesContentY + 11);
}
} else {
// ── Fourge sign page ───────────────────────────────────────────────────────
const availH = bottom - top;
const photoW = (W - MARGIN * 2 - 20) * 0.45;
const specsX = MARGIN + photoW + 20;
const specsW = W - MARGIN - specsX;
if (photoDataUrl) {
const photoImg = await loadImage(photoDataUrl);
if (photoImg) {
const cropped = toCoverImage(photoDataUrl, photoImg, photoW, availH, 0.8, SIGN_PX_PER_PT);
if (!cropped) continue;
doc.setFillColor(245, 245, 245);
doc.rect(MARGIN, top, photoW, availH, 'F');
doc.addImage(cropped.dataUrl, cropped.format, MARGIN, top, photoW, availH);
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH);
}
} else {
doc.setFillColor(245, 245, 245);
doc.rect(MARGIN, top, photoW, availH, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH);
}
let sy = top;
doc.setFillColor(...ACCENT);
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
sy += 26;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(30, 30, 30);
doc.text(sign.type || 'Sign Type', specsX, sy);
sy += 20;
doc.setDrawColor(...ACCENT);
doc.setLineWidth(1);
doc.line(specsX, sy, specsX + specsW, sy);
sy += 12;
const specs = [
['Location', sign.location || '-'],
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '-'],
['Material', sign.material || '— (placeholder)'],
['Illumination', sign.illumination || '— (placeholder)'],
['Condition', sign.condition || '— (placeholder)'],
['Mount Type', sign.mountType || '— (placeholder)'],
];
specs.forEach(([label, value]) => {
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text(label.toUpperCase(), specsX, sy);
sy += 9;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
const wrapped = doc.splitTextToSize(value, specsW);
doc.text(wrapped, specsX, sy);
sy += wrapped.length * 6 + 10;
});
if (sign.notes) {
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text('NOTES', specsX, sy);
sy += 9;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
const noteLines = doc.splitTextToSize(sign.notes, specsW);
doc.text(noteLines, specsX, sy);
}
}
}
// ─── SITE PHOTOS PAGES (4×4 grid, 16 per page) ───────────────────────────────
if (validSitePhotos.length > 0) {
const cols = 4;
const rows = 4;
const gapX = 8;
const gapY = 8;
const photoPageCount = Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE);
for (let pg = 0; pg < photoPageCount; pg++) {
pageNum++;
doc.addPage();
const pageLabel = photoPageCount > 1 ? `Site Photos (${pg + 1}/${photoPageCount})` : 'Site Photos';
if (template === 'bolchoz') {
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else {
addHeader(doc, logo, logoW, logoH, pageLabel, pageNum, totalPages);
addFooter(doc, clientName, displayDate);
}
const SITE_LABEL_H = template === 'bolchoz' ? 18 : 0;
const top = template === 'bolchoz' ? MARGIN + SITE_LABEL_H : HEADER_H + 14;
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const availW = W - MARGIN * 2;
const thumbW = (availW - gapX * (cols - 1)) / cols;
const thumbH = (bottom - top - gapY * (rows - 1)) / rows;
const pagePhotos = validSitePhotos.slice(pg * PHOTOS_PER_PAGE, (pg + 1) * PHOTOS_PER_PAGE);
for (let i = 0; i < pagePhotos.length; i++) {
const dataUrl = pagePhotos[i];
const col = i % cols;
const row = Math.floor(i / cols);
const tx = MARGIN + col * (thumbW + gapX);
const ty = top + row * (thumbH + gapY);
await drawCover(dataUrl, tx, ty, thumbW, thumbH, { quality: 0.72, pxPerPt: THUMB_PX_PER_PT });
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.rect(tx, ty, thumbW, thumbH);
}
// Draw header after photos so masks can't cover it
if (template === 'bolchoz') {
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('SITE PHOTOS', MARGIN, MARGIN + 10);
doc.setCharSpace(0);
}
}
}
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
const filename = [safePart(projectName), `R${rev}`].filter(Boolean).join('.');
doc.save(`${filename || 'BrandBook'}.pdf`);
}
function drawInventoryTable(doc, signs, x, y, w, h, template, startIndex = 0) {
const colDefs = template === 'bolchoz'
? [
{ label: '#', flex: 0.5 },
{ label: 'Existing', flex: 1.5 },
{ label: 'Recommendation', flex: 1.5 },
]
: [
{ label: '#', flex: 0.5 },
{ label: 'Type', flex: 1.5 },
{ label: 'Location', flex: 2 },
{ label: 'Dimensions', flex: 1.2 },
{ label: 'Notes', flex: 2 },
];
const totalFlex = colDefs.reduce((s, c) => s + c.flex, 0);
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
const rowH = 18;
doc.setFillColor(120, 120, 120);
doc.rect(x, y, w, rowH, 'F');
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
let cx = x;
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
let ry = y + rowH;
let nextIndex = startIndex;
for (let i = startIndex; i < signs.length; i++) {
if (ry + rowH > y + h) break;
const sign = signs[i];
const rowData = template === 'bolchoz'
? [
sign.signNumber || String(i + 1),
sign.type || '',
sign.recommendation || '',
]
: [
sign.signNumber || String(i + 1),
sign.type || '-',
sign.location || '-',
sign.width && sign.height ? `${sign.width}" × ${sign.height}"` : '-',
sign.notes || '',
];
doc.setFillColor(i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242);
doc.rect(x, ry, w, rowH, 'F');
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
cx = x;
cols.forEach((col, ci) => {
const truncated = doc.splitTextToSize(rowData[ci] || '', col.w - 8)[0] || '';
doc.text(truncated, cx + 5, ry + 12);
cx += col.w;
});
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.2);
doc.line(x, ry + rowH, x + w, ry + rowH);
ry += rowH;
nextIndex = i + 1;
}
doc.setDrawColor(180, 180, 180);
doc.setLineWidth(0.5);
doc.rect(x, y, w, ry - y);
if (signs.length === 0) {
doc.setFontSize(9);
doc.setFont('helvetica', 'italic');
doc.setTextColor(160, 160, 160);
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
}
return nextIndex;
}