eee0885811
- 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>
240 lines
7.7 KiB
JavaScript
240 lines
7.7 KiB
JavaScript
import jsPDF from 'jspdf';
|
|
|
|
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}T12:00:00`);
|
|
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
async function blobToDataUrl(blob) {
|
|
return new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => resolve(event.target?.result || null);
|
|
reader.onerror = () => resolve(null);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
async function resolvePhoto(source) {
|
|
if (!source) return null;
|
|
if (source instanceof File || source instanceof Blob) {
|
|
return blobToDataUrl(source);
|
|
}
|
|
if (typeof source === 'string') return source.startsWith('data:') ? source : null;
|
|
return null;
|
|
}
|
|
|
|
function getFormat(dataUrl) {
|
|
return dataUrl?.startsWith('data:image/png') ? 'PNG' : 'JPEG';
|
|
}
|
|
|
|
function loadImage(src) {
|
|
return new Promise((resolve) => {
|
|
if (!src) return resolve(null);
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => resolve(null);
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
const PX_PER_PT = 300 / 72;
|
|
|
|
function toPrintJpeg(dataUrl, img, displayPtW, displayPtH, quality = 0.82) {
|
|
if (!dataUrl || dataUrl.startsWith('data:image/png')) return dataUrl;
|
|
const tW = Math.round(displayPtW * PX_PER_PT);
|
|
const tH = Math.round(displayPtH * PX_PER_PT);
|
|
if (img.naturalWidth <= tW && img.naturalHeight <= tH) return dataUrl;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = tW;
|
|
canvas.height = tH;
|
|
canvas.getContext('2d').drawImage(img, 0, 0, tW, tH);
|
|
return canvas.toDataURL('image/jpeg', quality);
|
|
}
|
|
|
|
function addHeader(doc, 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);
|
|
|
|
doc.setFontSize(10);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(...DARK);
|
|
doc.text(title.toUpperCase(), MARGIN, HEADER_H / 2 + 4);
|
|
|
|
doc.setFontSize(8);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(140, 140, 140);
|
|
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 4, { align: 'right' });
|
|
}
|
|
|
|
function drawCoverImage(doc, dataUrl, img, x, y, w, h) {
|
|
if (!dataUrl || !img) return;
|
|
const srcAspect = img.naturalWidth / img.naturalHeight;
|
|
const boxAspect = w / h;
|
|
let drawW;
|
|
let drawH;
|
|
let drawX;
|
|
let drawY;
|
|
|
|
if (srcAspect > boxAspect) {
|
|
drawW = w;
|
|
drawH = w / srcAspect;
|
|
drawX = x;
|
|
drawY = y + (h - drawH) / 2;
|
|
} else {
|
|
drawH = h;
|
|
drawW = h * srcAspect;
|
|
drawX = x + (w - drawW) / 2;
|
|
drawY = y;
|
|
}
|
|
|
|
const printableDataUrl = toPrintJpeg(dataUrl, img, drawW, drawH);
|
|
doc.addImage(printableDataUrl, getFormat(printableDataUrl), drawX, drawY, drawW, drawH);
|
|
}
|
|
|
|
export async function generateSurveyMakerPdf(data) {
|
|
const { clientName, siteAddress, surveyDate, preparedBy, projectName, signs } = data;
|
|
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt', compress: true });
|
|
|
|
const signPhotos = await Promise.all((signs || []).map(async (sign) => {
|
|
const mainDataUrl = await resolvePhoto(sign.mainPhoto);
|
|
const contextDataUrls = await Promise.all([
|
|
resolvePhoto(sign.contextPhoto1),
|
|
resolvePhoto(sign.contextPhoto2),
|
|
resolvePhoto(sign.contextPhoto3),
|
|
]);
|
|
const mainImage = mainDataUrl ? await loadImage(mainDataUrl) : null;
|
|
const contextImages = await Promise.all(contextDataUrls.map(async (dataUrl) => (dataUrl ? loadImage(dataUrl) : null)));
|
|
|
|
return { mainDataUrl, mainImage, contextDataUrls, contextImages };
|
|
}));
|
|
|
|
const totalPages = Math.max(1, 1 + signs.length);
|
|
|
|
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');
|
|
|
|
doc.setFontSize(30);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(...DARK);
|
|
doc.text(projectName || 'Sign Survey', MARGIN, 88);
|
|
|
|
const details = [
|
|
['Client', clientName || '-'],
|
|
['Site Address', siteAddress || '-'],
|
|
['Survey Date', formatDate(surveyDate)],
|
|
['Prepared By', preparedBy || '-'],
|
|
['Sign Count', String(signs.length)],
|
|
];
|
|
|
|
let y = 148;
|
|
details.forEach(([label, value]) => {
|
|
doc.setFontSize(9);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(150, 150, 150);
|
|
doc.text(label.toUpperCase(), MARGIN, y);
|
|
y += 16;
|
|
doc.setFontSize(14);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(40, 40, 40);
|
|
const lines = doc.splitTextToSize(value, 320);
|
|
doc.text(lines, MARGIN, y);
|
|
y += lines.length * 16 + 18;
|
|
});
|
|
|
|
if (signPhotos[0]?.mainDataUrl && signPhotos[0]?.mainImage) {
|
|
const photoX = 410;
|
|
const photoY = 110;
|
|
const photoW = W - photoX - MARGIN;
|
|
const photoH = 360;
|
|
drawCoverImage(doc, signPhotos[0].mainDataUrl, signPhotos[0].mainImage, photoX, photoY, photoW, photoH);
|
|
}
|
|
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(140, 140, 140);
|
|
doc.text('1 / ' + totalPages, W - MARGIN, H - 18, { align: 'right' });
|
|
|
|
for (let index = 0; index < signs.length; index += 1) {
|
|
const sign = signs[index];
|
|
const photo = signPhotos[index];
|
|
doc.addPage();
|
|
addHeader(doc, sign.signName || `Sign ${index + 1}`, index + 2, totalPages);
|
|
|
|
const top = HEADER_H + 20;
|
|
const leftW = 270;
|
|
const rightX = MARGIN + leftW + 20;
|
|
const rightW = W - rightX - MARGIN;
|
|
const mainPhotoH = 280;
|
|
const contextGap = 10;
|
|
const contextPhotoY = top + mainPhotoH + 12;
|
|
const contextPhotoW = (rightW - contextGap * 2) / 3;
|
|
const contextPhotoH = H - contextPhotoY - 36;
|
|
|
|
doc.setFontSize(18);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(...DARK);
|
|
doc.text(sign.signName || `Sign ${index + 1}`, MARGIN, top + 10);
|
|
|
|
let textY = top + 42;
|
|
const blocks = [
|
|
['Measurements', sign.measurements || '-'],
|
|
['Notes', sign.notes || '-'],
|
|
];
|
|
|
|
blocks.forEach(([label, value]) => {
|
|
doc.setFontSize(9);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(150, 150, 150);
|
|
doc.text(label.toUpperCase(), MARGIN, textY);
|
|
textY += 16;
|
|
doc.setFontSize(11);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(50, 50, 50);
|
|
const lines = doc.splitTextToSize(value, leftW);
|
|
doc.text(lines, MARGIN, textY);
|
|
textY += lines.length * 14 + 24;
|
|
});
|
|
|
|
if (photo?.mainDataUrl && photo?.mainImage) {
|
|
drawCoverImage(doc, photo.mainDataUrl, photo.mainImage, rightX, top, rightW, mainPhotoH);
|
|
} else {
|
|
doc.setFontSize(10);
|
|
doc.setTextColor(170, 170, 170);
|
|
doc.text('No Main Photo', rightX + rightW / 2, top + mainPhotoH / 2, { align: 'center' });
|
|
}
|
|
|
|
for (let contextIndex = 0; contextIndex < 3; contextIndex += 1) {
|
|
const x = rightX + contextIndex * (contextPhotoW + contextGap);
|
|
|
|
const contextDataUrl = photo?.contextDataUrls?.[contextIndex];
|
|
const contextImage = photo?.contextImages?.[contextIndex];
|
|
if (contextDataUrl && contextImage) {
|
|
drawCoverImage(doc, contextDataUrl, contextImage, x, contextPhotoY, contextPhotoW, contextPhotoH);
|
|
} else {
|
|
doc.setFontSize(9);
|
|
doc.setTextColor(180, 180, 180);
|
|
doc.text(`Context ${contextIndex + 1}`, x + contextPhotoW / 2, contextPhotoY + contextPhotoH / 2, { align: 'center' });
|
|
}
|
|
}
|
|
}
|
|
|
|
const safe = (value) => (value || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
|
const filename = [safe(projectName || 'Survey'), safe(clientName), formatDate(surveyDate).replace(/[^a-zA-Z0-9]/g, '')].filter(Boolean).join('.');
|
|
doc.save(`${filename || 'SurveyMaker'}.pdf`);
|
|
}
|