feat: Advanced color palette extraction algorithm

- Build complete color palette instead of single dominant color
- Filter out whites (RGB > 240), blacks (RGB < 15), and grays (saturation < 0.15)
- Calculate HSL values for better color analysis
- Quantize colors to group similar shades together
- Score colors based on:
  * Saturation (prefer vibrant colors)
  * Frequency (weight by occurrence)
  * Optimal lightness (0.4-0.6 range preferred)
- Select best scoring color from palette
- Skip too light (> 0.85) or too dark (< 0.20) colors
- Improved sampling rate (every 4 pixels instead of 10)
- Console logging for debugging extracted palette
- Fallback to emerald green if no suitable colors found
This commit is contained in:
d.viti
2025-10-14 00:16:37 +02:00
parent e1173a1fbc
commit 546d7201b0

View File

@@ -735,17 +735,18 @@
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
try { try {
const imageData = ctx.getImageData( const imgData = ctx.getImageData(
0, 0,
0, 0,
canvas.width, canvas.width,
canvas.height, canvas.height,
); );
const data = imageData.data; const data = imgData.data;
// Sample colors (every 10 pixels for performance) // Collect color palette with counts
const colorMap = {}; const colorMap = {};
for (let i = 0; i < data.length; i += 40) { for (let i = 0; i < data.length; i += 16) {
// Sample every 4 pixels for better coverage
const r = data[i]; const r = data[i];
const g = data[i + 1]; const g = data[i + 1];
const b = data[i + 2]; const b = data[i + 2];
@@ -754,32 +755,93 @@
// Skip transparent pixels // Skip transparent pixels
if (a < 125) continue; if (a < 125) continue;
// Skip very light colors (likely background) // Skip whites (> 240 in all channels)
if (r > 240 && g > 240 && b > 240) continue; if (r > 240 && g > 240 && b > 240) continue;
const rgb = `${r},${g},${b}`; // Skip blacks (< 15 in all channels)
if (r < 15 && g < 15 && b < 15) continue;
// Skip grays (low saturation: when R≈G≈B)
const maxChan = Math.max(r, g, b);
const minChan = Math.min(r, g, b);
const saturation =
maxChan === 0
? 0
: (maxChan - minChan) / maxChan;
if (saturation < 0.15) continue; // Skip low saturation (grays)
// Quantize colors to reduce variations (group similar colors)
const qr = Math.round(r / 10) * 10;
const qg = Math.round(g / 10) * 10;
const qb = Math.round(b / 10) * 10;
const rgb = `${qr},${qg},${qb}`;
colorMap[rgb] = (colorMap[rgb] || 0) + 1; colorMap[rgb] = (colorMap[rgb] || 0) + 1;
} }
// Find most common color // Build palette with color scores
let maxCount = 0; const palette = [];
let dominantColor = null;
for (const [rgb, count] of Object.entries( for (const [rgb, count] of Object.entries(
colorMap, colorMap,
)) { )) {
if (count > maxCount) { const [r, g, b] = rgb
maxCount = count;
dominantColor = rgb;
}
}
if (dominantColor) {
const [r, g, b] = dominantColor
.split(",") .split(",")
.map(Number); .map(Number);
this.logoColor = this.rgbToHex(r, g, b);
// Calculate HSL for better color selection
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255; // Lightness 0-1
const s =
max === min
? 0
: (max - min) / (max + min); // Saturation 0-1
// Skip too light (> 0.85) or too dark (< 0.20)
if (l > 0.85 || l < 0.2) continue;
// Calculate color "score" based on:
// - Saturation (higher is better)
// - Frequency (more common is better)
// - Ideal lightness (0.4-0.6 is best)
const lightnessScore =
1 - Math.abs(l - 0.5) * 2; // Peaks at 0.5
const score =
s * 2 + count / 100 + lightnessScore;
palette.push({
r,
g,
b,
count,
saturation: s,
lightness: l,
score,
});
}
// Sort by score (best colors first)
palette.sort((a, b) => b.score - a.score);
// Get the best color from palette
if (palette.length > 0) {
const bestColor = palette[0];
this.logoColor = this.rgbToHex(
bestColor.r,
bestColor.g,
bestColor.b,
);
console.log(
"Extracted color:",
this.logoColor,
"from palette of",
palette.length,
"colors",
);
} else { } else {
this.logoColor = "#10b981"; // Fallback emerald this.logoColor = "#10b981"; // Fallback emerald
console.log(
"No suitable colors found, using fallback",
);
} }
} catch (err) { } catch (err) {
console.error("Error extracting color:", err); console.error("Error extracting color:", err);
@@ -915,277 +977,377 @@
}, 3000); }, 3000);
}, },
generaPDF() { generaPDF() {
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
const doc = new jsPDF(); const doc = new jsPDF();
let yPos = 0; let yPos = 0;
// Determine brand color (from logo or default) // Determine brand color (from logo or default)
const brandColor = this.logoColor || '#10b981'; const brandColor = this.logoColor || "#10b981";
const rgb = this.hexToRgb(brandColor); const rgb = this.hexToRgb(brandColor);
const lightRgb = { const lightRgb = {
r: Math.min(255, rgb.r + 180), r: Math.min(255, rgb.r + 180),
g: Math.min(255, rgb.g + 180), g: Math.min(255, rgb.g + 180),
b: Math.min(255, rgb.b + 180) b: Math.min(255, rgb.b + 180),
}; };
// ===== HEADER SECTION with gradient effect ===== // ===== HEADER SECTION with gradient effect =====
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b); doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
doc.rect(0, 0, 210, 55, 'F'); doc.rect(0, 0, 210, 55, "F");
doc.setFillColor(rgb.r, rgb.g, rgb.b); doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.rect(0, 35, 210, 20, 'F'); doc.rect(0, 35, 210, 20, "F");
yPos = 18; yPos = 18;
// Logo in header with white card background // Logo in header with white card background
if (this.venditore.logo) { if (this.venditore.logo) {
try { try {
doc.setFillColor(255, 255, 255); doc.setFillColor(255, 255, 255);
doc.roundedRect(15, yPos - 8, 35, 35, 3, 3, 'F'); doc.roundedRect(
doc.setDrawColor(230, 230, 230); 15,
doc.setLineWidth(0.5); yPos - 8,
doc.roundedRect(15, yPos - 8, 35, 35, 3, 3, 'S'); 35,
doc.addImage(this.venditore.logo, 'PNG', 18, yPos - 5, 29, 29); 35,
} catch (err) { 3,
console.error('Error adding logo to PDF:', err); 3,
} "F",
} );
doc.setDrawColor(230, 230, 230);
doc.setLineWidth(0.5);
doc.roundedRect(
15,
yPos - 8,
35,
35,
3,
3,
"S",
);
doc.addImage(
this.venditore.logo,
"PNG",
18,
yPos - 5,
29,
29,
);
} catch (err) {
console.error("Error adding logo to PDF:", err);
}
}
// Company name and title // Company name and title
const xStart = this.venditore.logo ? 55 : 15; const xStart = this.venditore.logo ? 55 : 15;
doc.setFontSize(26); doc.setFontSize(26);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60); doc.setTextColor(60, 60, 60);
doc.text(this.venditore.nome || "Venditore", xStart, yPos); doc.text(
this.venditore.nome || "Venditore",
xStart,
yPos,
);
doc.setFontSize(14); doc.setFontSize(14);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.text("PREVENTIVO", xStart, yPos + 23); doc.text("PREVENTIVO", xStart, yPos + 23);
// Date badge // Date badge
const dateStr = new Date(this.cliente.data).toLocaleDateString('it-IT'); const dateStr = new Date(
doc.setFillColor(255, 255, 255); this.cliente.data,
doc.roundedRect(155, yPos + 15, 40, 12, 2, 2, 'F'); ).toLocaleDateString("it-IT");
doc.setFontSize(10); doc.setFillColor(255, 255, 255);
doc.setFont(undefined, 'bold'); doc.roundedRect(155, yPos + 15, 40, 12, 2, 2, "F");
doc.setTextColor(rgb.r, rgb.g, rgb.b); doc.setFontSize(10);
doc.text(dateStr, 175, yPos + 22, { align: 'center' }); doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text(dateStr, 175, yPos + 22, { align: "center" });
yPos = 65; yPos = 65;
// ===== INFO CARDS SECTION ===== // ===== INFO CARDS SECTION =====
// Venditore card // Venditore card
doc.setFillColor(250, 250, 250); doc.setFillColor(250, 250, 250);
doc.roundedRect(15, yPos, 85, 28, 3, 3, 'F'); doc.roundedRect(15, yPos, 85, 28, 3, 3, "F");
doc.setDrawColor(220, 220, 220); doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5); doc.setLineWidth(0.5);
doc.roundedRect(15, yPos, 85, 28, 3, 3, 'S'); doc.roundedRect(15, yPos, 85, 28, 3, 3, "S");
// Venditore header with brand color // Venditore header with brand color
doc.setFillColor(rgb.r, rgb.g, rgb.b); doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.roundedRect(15, yPos, 85, 8, 3, 3, 'F'); doc.roundedRect(15, yPos, 85, 8, 3, 3, "F");
doc.rect(15, yPos + 5, 85, 3, 'F'); doc.rect(15, yPos + 5, 85, 3, "F");
doc.setFontSize(9); doc.setFontSize(9);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.text("VENDITORE", 57.5, yPos + 5.5, { align: 'center' }); doc.text("VENDITORE", 57.5, yPos + 5.5, {
align: "center",
});
// Venditore data // Venditore data
doc.setFontSize(8); doc.setFontSize(8);
doc.setFont(undefined, 'normal'); doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60); doc.setTextColor(60, 60, 60);
doc.text(this.venditore.nome || '-', 18, yPos + 13); doc.text(this.venditore.nome || "-", 18, yPos + 13);
doc.text(`P.IVA: ${this.venditore.piva || '-'}`, 18, yPos + 18); doc.text(
doc.text(this.venditore.indirizzo || '-', 18, yPos + 23); `P.IVA: ${this.venditore.piva || "-"}`,
18,
yPos + 18,
);
doc.text(
this.venditore.indirizzo || "-",
18,
yPos + 23,
);
// Cliente card // Cliente card
doc.setFillColor(250, 250, 250); doc.setFillColor(250, 250, 250);
doc.roundedRect(110, yPos, 85, 28, 3, 3, 'F'); doc.roundedRect(110, yPos, 85, 28, 3, 3, "F");
doc.setDrawColor(220, 220, 220); doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5); doc.setLineWidth(0.5);
doc.roundedRect(110, yPos, 85, 28, 3, 3, 'S'); doc.roundedRect(110, yPos, 85, 28, 3, 3, "S");
// Cliente header with brand color // Cliente header with brand color
doc.setFillColor(rgb.r, rgb.g, rgb.b); doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.roundedRect(110, yPos, 85, 8, 3, 3, 'F'); doc.roundedRect(110, yPos, 85, 8, 3, 3, "F");
doc.rect(110, yPos + 5, 85, 3, 'F'); doc.rect(110, yPos + 5, 85, 3, "F");
doc.setFontSize(9); doc.setFontSize(9);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.text("CLIENTE", 152.5, yPos + 5.5, { align: 'center' }); doc.text("CLIENTE", 152.5, yPos + 5.5, {
align: "center",
});
// Cliente data // Cliente data
doc.setFontSize(8); doc.setFontSize(8);
doc.setFont(undefined, 'normal'); doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60); doc.setTextColor(60, 60, 60);
doc.text(this.cliente.nome || '-', 113, yPos + 13); doc.text(this.cliente.nome || "-", 113, yPos + 13);
doc.text(this.cliente.email || '-', 113, yPos + 18); doc.text(this.cliente.email || "-", 113, yPos + 18);
doc.text(this.cliente.telefono || '-', 113, yPos + 23); doc.text(this.cliente.telefono || "-", 113, yPos + 23);
yPos += 38; yPos += 38;
// ===== ITEMS TABLE ===== // ===== ITEMS TABLE =====
// Table header with gradient // Table header with gradient
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b); doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
doc.roundedRect(15, yPos, 180, 10, 2, 2, 'F'); doc.roundedRect(15, yPos, 180, 10, 2, 2, "F");
doc.setFillColor(rgb.r, rgb.g, rgb.b); doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.roundedRect(15, yPos, 180, 10, 2, 2, 'F'); doc.roundedRect(15, yPos, 180, 10, 2, 2, "F");
doc.setFontSize(8); doc.setFontSize(8);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.text("ARTICOLO", 18, yPos + 6.5); doc.text("ARTICOLO", 18, yPos + 6.5);
doc.text("Q.TÀ", 110, yPos + 6.5, { align: 'center' }); doc.text("Q.TÀ", 110, yPos + 6.5, { align: "center" });
doc.text("PREZZO", 130, yPos + 6.5, { align: 'center' }); doc.text("PREZZO", 130, yPos + 6.5, {
doc.text("SC%", 150, yPos + 6.5, { align: 'center' }); align: "center",
doc.text("IVA%", 165, yPos + 6.5, { align: 'center' }); });
doc.text("TOTALE", 188, yPos + 6.5, { align: 'right' }); doc.text("SC%", 150, yPos + 6.5, { align: "center" });
doc.text("IVA%", 165, yPos + 6.5, { align: "center" });
doc.text("TOTALE", 188, yPos + 6.5, { align: "right" });
yPos += 14; yPos += 14;
// Items rows with alternating background // Items rows with alternating background
let itemIndex = 0; let itemIndex = 0;
this.items.forEach((item) => { this.items.forEach((item) => {
if (yPos > 255) { if (yPos > 255) {
doc.addPage(); doc.addPage();
yPos = 20; yPos = 20;
itemIndex = 0; itemIndex = 0;
} }
// Alternating row background // Alternating row background
if (itemIndex % 2 === 0) { if (itemIndex % 2 === 0) {
doc.setFillColor(250, 250, 250); doc.setFillColor(250, 250, 250);
doc.rect(15, yPos - 2, 180, 10, 'F'); doc.rect(15, yPos - 2, 180, 10, "F");
} }
// Item data // Item data
doc.setFontSize(8); doc.setFontSize(8);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60); doc.setTextColor(60, 60, 60);
doc.text(item.name.substring(0, 45), 18, yPos + 2); doc.text(item.name.substring(0, 45), 18, yPos + 2);
doc.setFont(undefined, 'normal'); doc.setFont(undefined, "normal");
doc.text(String(item.quantity), 110, yPos + 2, { align: 'center' }); doc.text(String(item.quantity), 110, yPos + 2, {
doc.text(`${item.price.toFixed(2)}`, 130, yPos + 2, { align: 'center' }); align: "center",
doc.text(`${item.discount}%`, 150, yPos + 2, { align: 'center' }); });
doc.text(`${item.iva || 0}%`, 165, yPos + 2, { align: 'center' }); doc.text(
`${item.price.toFixed(2)}`,
130,
yPos + 2,
{ align: "center" },
);
doc.text(`${item.discount}%`, 150, yPos + 2, {
align: "center",
});
doc.text(`${item.iva || 0}%`, 165, yPos + 2, {
align: "center",
});
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b); doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text(`${this.calculateItemTotal(item).toFixed(2)}`, 188, yPos + 2, { align: 'right' }); doc.text(
`${this.calculateItemTotal(item).toFixed(2)}`,
188,
yPos + 2,
{ align: "right" },
);
// SKU and description // SKU and description
if (item.sku || item.description) { if (item.sku || item.description) {
yPos += 5; yPos += 5;
doc.setFontSize(7); doc.setFontSize(7);
doc.setTextColor(120, 120, 120); doc.setTextColor(120, 120, 120);
doc.setFont(undefined, 'italic'); doc.setFont(undefined, "italic");
if (item.sku) { if (item.sku) {
doc.text(`SKU: ${item.sku}`, 18, yPos); doc.text(`SKU: ${item.sku}`, 18, yPos);
yPos += 3.5; yPos += 3.5;
} }
if (item.description) { if (item.description) {
const descLines = doc.splitTextToSize(item.description, 165); const descLines = doc.splitTextToSize(
descLines.slice(0, 2).forEach(line => { item.description,
doc.text(line, 18, yPos); 165,
yPos += 3.5; );
}); descLines.slice(0, 2).forEach((line) => {
} doc.text(line, 18, yPos);
yPos += 2; yPos += 3.5;
} else { });
yPos += 10; }
} yPos += 2;
} else {
yPos += 10;
}
// Separator line // Separator line
doc.setDrawColor(230, 230, 230); doc.setDrawColor(230, 230, 230);
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
doc.line(15, yPos - 1, 195, yPos - 1); doc.line(15, yPos - 1, 195, yPos - 1);
itemIndex++; itemIndex++;
}); });
yPos += 5; yPos += 5;
// ===== TOTALS SECTION ===== // ===== TOTALS SECTION =====
// Summary box // Summary box
doc.setFillColor(250, 250, 250); doc.setFillColor(250, 250, 250);
doc.roundedRect(115, yPos, 80, 45, 3, 3, 'F'); doc.roundedRect(115, yPos, 80, 45, 3, 3, "F");
doc.setDrawColor(220, 220, 220); doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5); doc.setLineWidth(0.5);
doc.roundedRect(115, yPos, 80, 45, 3, 3, 'S'); doc.roundedRect(115, yPos, 80, 45, 3, 3, "S");
let summaryY = yPos + 8; let summaryY = yPos + 8;
doc.setFontSize(9); doc.setFontSize(9);
doc.setFont(undefined, 'normal'); doc.setFont(undefined, "normal");
doc.setTextColor(80, 80, 80); doc.setTextColor(80, 80, 80);
// Subtotal // Subtotal
doc.text("Subtotale:", 120, summaryY); doc.text("Subtotale:", 120, summaryY);
doc.text(this.formatCurrency(this.calculateSubtotal()), 190, summaryY, { align: 'right' }); doc.text(
summaryY += 6; this.formatCurrency(this.calculateSubtotal()),
190,
summaryY,
{ align: "right" },
);
summaryY += 6;
// Discount // Discount
if (this.calculateTotalDiscount() > 0) { if (this.calculateTotalDiscount() > 0) {
doc.text("Sconto:", 120, summaryY); doc.text("Sconto:", 120, summaryY);
doc.setTextColor(220, 50, 50); doc.setTextColor(220, 50, 50);
doc.text(`-${this.formatCurrency(this.calculateTotalDiscount())}`, 190, summaryY, { align: 'right' }); doc.text(
doc.setTextColor(80, 80, 80); `-${this.formatCurrency(this.calculateTotalDiscount())}`,
summaryY += 6; 190,
} summaryY,
{ align: "right" },
);
doc.setTextColor(80, 80, 80);
summaryY += 6;
}
// Shipping // Shipping
if (this.shippingCost > 0) { if (this.shippingCost > 0) {
doc.text("Spedizione:", 120, summaryY); doc.text("Spedizione:", 120, summaryY);
doc.text(this.formatCurrency(this.shippingCost), 190, summaryY, { align: 'right' }); doc.text(
summaryY += 6; this.formatCurrency(this.shippingCost),
} 190,
summaryY,
{ align: "right" },
);
summaryY += 6;
}
// Taxable amount // Taxable amount
doc.text("Imponibile:", 120, summaryY); doc.text("Imponibile:", 120, summaryY);
doc.text(this.formatCurrency(this.calculateTaxableAmount()), 190, summaryY, { align: 'right' }); doc.text(
summaryY += 6; this.formatCurrency(this.calculateTaxableAmount()),
190,
summaryY,
{ align: "right" },
);
summaryY += 6;
// VAT // VAT
if (this.calculateTax() > 0) { if (this.calculateTax() > 0) {
doc.text("IVA:", 120, summaryY); doc.text("IVA:", 120, summaryY);
doc.text(this.formatCurrency(this.calculateTax()), 190, summaryY, { align: 'right' }); doc.text(
summaryY += 8; this.formatCurrency(this.calculateTax()),
} else { 190,
summaryY += 8; summaryY,
} { align: "right" },
);
summaryY += 8;
} else {
summaryY += 8;
}
// Total with brand color background // Total with brand color background
doc.setFillColor(rgb.r, rgb.g, rgb.b); doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.roundedRect(115, summaryY - 4, 80, 10, 2, 2, 'F'); doc.roundedRect(115, summaryY - 4, 80, 10, 2, 2, "F");
doc.setFontSize(11); doc.setFontSize(11);
doc.setFont(undefined, 'bold'); doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.text("TOTALE:", 120, summaryY + 2.5); doc.text("TOTALE:", 120, summaryY + 2.5);
doc.setFontSize(13); doc.setFontSize(13);
doc.text(this.formatCurrency(this.calculateTotal()), 190, summaryY + 2.5, { align: 'right' }); doc.text(
this.formatCurrency(this.calculateTotal()),
190,
summaryY + 2.5,
{ align: "right" },
);
// Footer // Footer
yPos += 55; yPos += 55;
if (yPos < 270) { if (yPos < 270) {
doc.setFontSize(7); doc.setFontSize(7);
doc.setFont(undefined, 'italic'); doc.setFont(undefined, "italic");
doc.setTextColor(150, 150, 150); doc.setTextColor(150, 150, 150);
doc.text(`Documento generato il ${new Date().toLocaleString('it-IT')}`, 105, 285, { align: 'center' }); doc.text(
} `Documento generato il ${new Date().toLocaleString("it-IT")}`,
105,
285,
{ align: "center" },
);
}
// Save PDF // Save PDF
const filename = `Preventivo_${this.cliente.nome || "Cliente"}_${new Date().toISOString().split("T")[0]}.pdf`; const filename = `Preventivo_${this.cliente.nome || "Cliente"}_${new Date().toISOString().split("T")[0]}.pdf`;
doc.save(filename); doc.save(filename);
this.showNotification("PDF generato con successo!", "success"); this.showNotification(
}, "PDF generato con successo!",
"success",
);
},
salvaDati() { salvaDati() {
const data = { const data = {
venditore: this.venditore, venditore: this.venditore,