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 1–8; 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; }