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>
This commit is contained in:
+554
-159
@@ -14,33 +14,252 @@ function formatDate(dateStr) {
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Accepts File, data URL string, or https URL string — returns data URL
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(source);
|
||||
});
|
||||
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 source;
|
||||
if (source.startsWith('data:')) {
|
||||
return ensurePdfSafeDataUrl(source, getDataUrlMimeType(source) === 'image/png');
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(source);
|
||||
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();
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(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);
|
||||
@@ -98,13 +317,11 @@ const BOLCHOZ_FOOTER_H = BOLCHOZ_LOGO_H + 8; // space reserved at bottom for foo
|
||||
|
||||
function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg) {
|
||||
const logoY = H - MARGIN - BOLCHOZ_LOGO_H;
|
||||
const pgDivY = H - MARGIN - 3.5; // vertical center of 10pt text
|
||||
|
||||
let footerLogoW = 0;
|
||||
if (clientLogoDataUrl && clientLogoImg) {
|
||||
const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight;
|
||||
footerLogoW = BOLCHOZ_LOGO_H * aspect;
|
||||
doc.addImage(clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
|
||||
addDataUrlImage(doc, clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
|
||||
}
|
||||
|
||||
const pgLabel = `Page ${String(pageNum).padStart(2, '0')} of ${String(totalPages).padStart(2, '0')}`;
|
||||
@@ -113,14 +330,6 @@ function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLog
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(pgLabel, W - MARGIN, H - MARGIN, { align: 'right' });
|
||||
|
||||
const pgLabelW = doc.getTextWidth(pgLabel);
|
||||
const divStartX = MARGIN + (footerLogoW > 0 ? footerLogoW + 12 : 0);
|
||||
const divEndX = W - MARGIN - pgLabelW - 10;
|
||||
if (divEndX > divStartX) {
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(divStartX, pgDivY, divEndX, pgDivY);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateBrandBookEditorPDF(data) {
|
||||
@@ -131,7 +340,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
clientContactName, clientContactEmail, clientContactPhone,
|
||||
approvedDate, approvalNotes } = data;
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt', compress: true });
|
||||
|
||||
// Load assets
|
||||
const logo = await loadImage('/fourge-logo.png');
|
||||
@@ -147,14 +356,28 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
]);
|
||||
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
|
||||
|
||||
@@ -186,7 +409,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
let dW, dH;
|
||||
if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; }
|
||||
else { dH = logoBoxSize; dW = dH * ratio; }
|
||||
doc.addImage(projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
|
||||
addDataUrlImage(doc, projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(9);
|
||||
@@ -238,18 +461,14 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
doc.setTextColor(30, 30, 30);
|
||||
if (revisionDate) doc.text(formatDate(revisionDate), rtx(formatDate(revisionDate)), ty);
|
||||
|
||||
const sepY = logoBoxY + logoBoxSize + 10;
|
||||
|
||||
const botY = sepY + 14;
|
||||
const halfW = (W - MARGIN * 2 - 20) / 2;
|
||||
const rightColX = MARGIN + halfW + 20;
|
||||
|
||||
// ── Bottom left: Customer (anchored to bottom-left corner) ──────────────────
|
||||
const addrText = customerAddress || siteAddress || '';
|
||||
const addrLineH = 11;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const addrLines = addrText ? doc.splitTextToSize(addrText, halfW) : [];
|
||||
const addrLines = formatCoverAddress(addrText);
|
||||
|
||||
// Work bottom-up to anchor to H - MARGIN
|
||||
const lY_addr = H - MARGIN;
|
||||
@@ -364,7 +583,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
dH = clientLogoH; dW = dH * ratio;
|
||||
dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop;
|
||||
}
|
||||
doc.addImage(clientLogoDataUrl, dx, dy, dW, dH);
|
||||
addDataUrlImage(doc, clientLogoDataUrl, dx, dy, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(8);
|
||||
@@ -390,6 +609,25 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
}
|
||||
|
||||
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' });
|
||||
@@ -407,14 +645,20 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
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 (siteAddress) metaParts.push(siteAddress);
|
||||
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, { align: 'center' });
|
||||
doc.text(metaParts.join(' · '), W / 2, cY + 36 + (coverAddressLines.length * 10), { align: 'center' });
|
||||
}
|
||||
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
@@ -428,17 +672,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
if (template === 'bolchoz') {
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
|
||||
// "Site Map" header top-left
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SITE MAP', MARGIN, MARGIN);
|
||||
doc.setCharSpace(0);
|
||||
|
||||
const smLabelH = 22; // space below label before map
|
||||
const smLabelH = 18; // space below label before map
|
||||
const smTop = MARGIN + smLabelH;
|
||||
const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H;
|
||||
const smW = W - MARGIN * 2;
|
||||
@@ -450,12 +684,29 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
const boxAspect = smW / smH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
|
||||
dh = smH; dw = dh * aspect;
|
||||
dx = MARGIN - (dw - smW) / 2; dy = smTop;
|
||||
} else {
|
||||
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
|
||||
dw = smW; dh = dw / aspect;
|
||||
dx = MARGIN; dy = smTop - (dh - smH) / 2;
|
||||
}
|
||||
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
|
||||
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);
|
||||
@@ -475,7 +726,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
}
|
||||
doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4);
|
||||
doc.rect(MARGIN, smTop, smW, smH);
|
||||
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
|
||||
addDataUrlImage(doc, toMapJpeg(siteMapDataUrl, smImg, dw, dh), dx, dy, dw, dh);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,7 +735,8 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
|
||||
const invContentTop = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
|
||||
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;
|
||||
|
||||
@@ -513,7 +765,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
drawW = mapW; drawH = mapW / imgAspect;
|
||||
drawX = mapX; drawY = mapY - (drawH - invContentH) / 2;
|
||||
}
|
||||
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
addDataUrlImage(doc, toMapJpeg(inventoryMapDataUrl, invMapImg, drawW, drawH), drawX, drawY, drawW, drawH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +783,15 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
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);
|
||||
@@ -548,7 +809,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
drawH = invContentH; drawW = invContentH * imgAspect;
|
||||
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
|
||||
}
|
||||
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
addDataUrlImage(doc, toMapJpeg(inventoryMapDataUrl, invMapImg, drawW, drawH), drawX, drawY, drawW, drawH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(14);
|
||||
@@ -558,7 +819,40 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
}
|
||||
}
|
||||
|
||||
drawInventoryTable(doc, signs, tableX, invContentTop, tableW, invContentH, template);
|
||||
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++) {
|
||||
@@ -576,98 +870,197 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
|
||||
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
|
||||
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
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 imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
|
||||
const boxAspect = photoW / availH;
|
||||
let dw, dh, dx, dy;
|
||||
if (imgAspect > boxAspect) {
|
||||
dw = photoW; dh = photoW / imgAspect;
|
||||
dx = MARGIN; dy = top + (availH - dh) / 2;
|
||||
} else {
|
||||
dh = availH; dw = availH * imgAspect;
|
||||
dx = MARGIN + (photoW - dw) / 2; dy = top;
|
||||
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.addImage(photoDataUrl, dx, dy, dw, dh);
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
|
||||
let sy = top;
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.roundedRect(specsX, sy, 44, 18, 3, 3, '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;
|
||||
});
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
|
||||
sy += 26;
|
||||
|
||||
if (sign.notes) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFontSize(16);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,7 +1083,8 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
addFooter(doc, clientName, displayDate);
|
||||
}
|
||||
|
||||
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 14;
|
||||
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;
|
||||
@@ -704,34 +1098,30 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
const tx = MARGIN + col * (thumbW + gapX);
|
||||
const ty = top + row * (thumbH + gapY);
|
||||
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(tx, ty, thumbW, thumbH, 'F');
|
||||
|
||||
const img = await loadImage(dataUrl);
|
||||
if (img) {
|
||||
const aspect = img.naturalWidth / img.naturalHeight;
|
||||
const boxAspect = thumbW / thumbH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
|
||||
} else {
|
||||
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
|
||||
}
|
||||
doc.addImage(dataUrl, dx, dy, dw, dh);
|
||||
}
|
||||
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), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
|
||||
const filename = [safePart(projectName), `R${rev}`].filter(Boolean).join('.');
|
||||
doc.save(`${filename || 'BrandBook'}.pdf`);
|
||||
}
|
||||
|
||||
function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
function drawInventoryTable(doc, signs, x, y, w, h, template, startIndex = 0) {
|
||||
const colDefs = template === 'bolchoz'
|
||||
? [
|
||||
{ label: '#', flex: 0.5 },
|
||||
@@ -749,18 +1139,20 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
|
||||
|
||||
const rowH = 18;
|
||||
doc.setFillColor(...DARK);
|
||||
doc.setFillColor(120, 120, 120);
|
||||
doc.rect(x, y, w, rowH, 'F');
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT);
|
||||
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;
|
||||
signs.forEach((sign, i) => {
|
||||
if (ry > y + h - rowH) return;
|
||||
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),
|
||||
@@ -792,7 +1184,8 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
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);
|
||||
@@ -804,4 +1197,6 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user