/** * Client-side invoice PDF: نوع محصول · عدد · قیمت · عکس * Site palette + KikoMiko wordmark linked to kikomiko.ir */ const PALETTE = { ink: "#22191a", background: "#fffcfb", soft: "#f8f1ef", blush: "#f4e4e0", berry: "#9a3d4f", muted: "#8a7874", gold: "#c4a07a", cardBorder: "#eaddd8", white: "#ffffff", }; const SITE_URL = "https://kikomiko.ir/"; const SITE_HOST = "kikomiko.ir"; function formatFaInt(n) { return new Intl.NumberFormat("fa-IR", { maximumFractionDigits: 0 }).format(n); } function loadImage(src) { return new Promise((resolve) => { if (!src) { resolve(null); return; } const img = new Image(); img.decoding = "async"; img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = src; }); } function wrapText(ctx, text, maxWidth) { const words = String(text || "").split(/\s+/).filter(Boolean); if (!words.length) return [""]; const lines = []; let line = words[0]; for (let i = 1; i < words.length; i++) { const test = `${line} ${words[i]}`; if (ctx.measureText(test).width <= maxWidth) line = test; else { lines.push(line); line = words[i]; } } lines.push(line); return lines; } async function ensureInvoiceFonts() { try { if (document.fonts?.load) { await Promise.all([ document.fonts.load('700 28px "Yekan Bakh"'), document.fonts.load('300 28px "Yekan Bakh"'), document.fonts.load('400 13px "Yekan Bakh"'), ]); } } catch { /* fallback fonts below */ } } /** * Draw site wordmark (Kiko bold berry + Miko light ink) + kikomiko.ir under it. * @returns {{ x: number; y: number; w: number; h: number }} hit area for PDF link */ function drawBrandLogo(ctx, fontFamily, rightX, topY) { const mark = "Kiko"; const tail = "Miko"; ctx.save(); ctx.direction = "ltr"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.font = `700 26px ${fontFamily}`; const markW = ctx.measureText(mark).width; ctx.font = `300 26px ${fontFamily}`; const tailW = ctx.measureText(tail).width; const logoW = markW + tailW; const logoX = rightX - logoW; ctx.font = `700 26px ${fontFamily}`; ctx.fillStyle = PALETTE.berry; ctx.fillText(mark, logoX, topY); ctx.font = `300 26px ${fontFamily}`; ctx.fillStyle = PALETTE.ink; ctx.fillText(tail, logoX + markW, topY); ctx.font = `500 11px ${fontFamily}`; ctx.fillStyle = PALETTE.berry; const urlW = ctx.measureText(SITE_HOST).width; ctx.fillText(SITE_HOST, rightX - urlW, topY + 32); ctx.strokeStyle = PALETTE.gold; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(rightX - urlW, topY + 45); ctx.lineTo(rightX, topY + 45); ctx.stroke(); ctx.restore(); return { x: Math.min(logoX, rightX - urlW), y: topY, w: Math.max(logoW, urlW), h: 48, }; } /** * Embed JPEG page images into a PDF (A4). Optional link rects in JPEG pixel space (top-left origin). * @param {Array<{ bytes: Uint8Array; w: number; h: number; links?: Array<{ x: number; y: number; w: number; h: number; url: string }> }>} pages */ function jpegsToPdfBlob(pages) { const pageW = 595.28; const pageH = 841.89; const margin = 18; const encoder = new TextEncoder(); const objects = []; function addObj(body) { objects.push(body); return objects.length; } function pdfEscape(s) { return String(s).replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); } const fontId = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`); const pageIds = []; for (const page of pages) { const maxW = pageW - margin * 2; const maxH = pageH - margin * 2; const fit = Math.min(maxW / page.w, maxH / page.h, 1); const dw = page.w * fit; const dh = page.h * fit; const x = (pageW - dw) / 2; const y = pageH - margin - dh; const annotIds = []; for (const link of page.links || []) { const sx = dw / page.w; const sy = dh / page.h; const left = x + link.x * sx; const bottom = y + (page.h - link.y - link.h) * sy; const right = left + link.w * sx; const top = bottom + link.h * sy; const annotId = addObj( `<< /Type /Annot /Subtype /Link /Rect [${left.toFixed(2)} ${bottom.toFixed(2)} ${right.toFixed(2)} ${top.toFixed(2)}] /Border [0 0 0] /A << /S /URI /URI (${pdfEscape(link.url)}) >> >>` ); annotIds.push(annotId); } const imgId = addObj("IMG"); const contentStream = `q\n${dw.toFixed(2)} 0 0 ${dh.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm\n/Im0 Do\nQ\n`; const contentId = addObj( `<< /Length ${encoder.encode(contentStream).length} >>\nstream\n${contentStream}\nendstream` ); const annots = annotIds.length > 0 ? ` /Annots [${annotIds.map((id) => `${id} 0 R`).join(" ")}]` : ""; const pageId = addObj( `<< /Type /Page /Parent 0 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentId} 0 R /Resources << /XObject << /Im0 ${imgId} 0 R >> /Font << /F1 ${fontId} 0 R >> >>${annots} >>` ); pageIds.push({ pageId, imgId, jpeg: page.bytes, imgW: page.w, imgH: page.h }); } const kids = pageIds.map((p) => `${p.pageId} 0 R`).join(" "); const pagesId = addObj(`<< /Type /Pages /Kids [${kids}] /Count ${pageIds.length} >>`); const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`); for (const p of pageIds) { objects[p.pageId - 1] = objects[p.pageId - 1].replace( "/Parent 0 0 R", `/Parent ${pagesId} 0 R` ); } const imgIdSet = new Map(pageIds.map((p) => [p.imgId, p])); const parts = []; const offsets = [0]; let offset = 0; function pushStr(s) { const b = typeof s === "string" ? encoder.encode(s) : s; parts.push(b); offset += b.length; } pushStr("%PDF-1.4\n"); for (let i = 0; i < objects.length; i++) { offsets.push(offset); const n = i + 1; const imgPage = imgIdSet.get(n); if (imgPage) { const jpeg = imgPage.jpeg; pushStr(`${n} 0 obj\n`); pushStr( `<< /Type /XObject /Subtype /Image /Width ${imgPage.imgW} /Height ${imgPage.imgH} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n` ); parts.push(jpeg); offset += jpeg.length; pushStr("\nendstream\nendobj\n"); } else { pushStr(`${n} 0 obj\n${objects[i]}\nendobj\n`); } } const xrefStart = offset; pushStr(`xref\n0 ${objects.length + 1}\n`); pushStr("0000000000 65535 f \n"); for (let i = 1; i <= objects.length; i++) { pushStr(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`); } pushStr( `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n` ); const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return new Blob([out], { type: "application/pdf" }); } function canvasToJpegBytes(canvas, quality = 0.92) { return new Promise((resolve, reject) => { canvas.toBlob( async (blob) => { if (!blob) { reject(new Error("JPEG encode failed")); return; } resolve(new Uint8Array(await blob.arrayBuffer())); }, "image/jpeg", quality ); }); } /** * @param {{ * orderId: string; * dateLabel: string; * customerName: string; * customerPhone?: string; * address?: string; * city?: string; * province?: string; * items: Array<{ name: string; category?: string|null; quantity: number; price: number; image?: string|null }>; * totalLabel: string; * formatPrice: (n: number) => string; * eurToTomanRate?: number; * }} data * @returns {Promise} */ export async function generateInvoicePdf(data) { await ensureInvoiceFonts(); const images = await Promise.all(data.items.map((i) => loadImage(i.image))); const W = 794; const pad = 36; const thumbW = 72; const thumbH = 96; const colQty = 64; const colPrice = 130; const colImg = 92; const colType = W - pad * 2 - colQty - colPrice - colImg; const rowPad = 10; const font = '"Yekan Bakh",Tahoma,"Segoe UI",Arial,sans-serif'; const measure = document.createElement("canvas").getContext("2d"); if (!measure) throw new Error("Canvas unavailable"); measure.font = `12px ${font}`; const rowHeights = data.items.map((item) => { const type = item.category || "—"; const nameLines = wrapText(measure, item.name || "", colType - 16); const typeLines = wrapText(measure, type, colType - 16); const textH = (typeLines.length + nameLines.length) * 16 + 8; return Math.max(thumbH + rowPad * 2, textH + rowPad * 2); }); const headerBandH = 64; const infoBlockEstimate = 70; const tableHeadH = 36; const footerH = 88; const tableH = rowHeights.reduce((a, b) => a + b, 0); const H = pad + headerBandH + 16 + infoBlockEstimate + tableHeadH + tableH + footerH; const scale = 2; const canvas = document.createElement("canvas"); canvas.width = W * scale; canvas.height = H * scale; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas unavailable"); ctx.scale(scale, scale); ctx.fillStyle = PALETTE.background; ctx.fillRect(0, 0, W, H); ctx.textBaseline = "top"; ctx.direction = "rtl"; let y = 0; ctx.fillStyle = PALETTE.soft; ctx.fillRect(0, 0, W, headerBandH); ctx.fillStyle = PALETTE.berry; ctx.fillRect(0, headerBandH - 3, W, 3); y = 14; const logoHit = drawBrandLogo(ctx, font, W - pad, y); ctx.textAlign = "left"; ctx.direction = "ltr"; ctx.fillStyle = PALETTE.ink; ctx.font = `700 12px ${font}`; ctx.fillText(data.orderId, pad, y + 8); ctx.font = `400 11px ${font}`; ctx.fillStyle = PALETTE.muted; ctx.fillText(data.dateLabel, pad, y + 28); ctx.direction = "rtl"; y = headerBandH + 18; ctx.fillStyle = PALETTE.ink; ctx.font = `400 13px ${font}`; ctx.textAlign = "right"; const info = [ `نام مشتری: ${data.customerName}`, data.customerPhone ? `تلفن: ${data.customerPhone}` : "", data.address ? `آدرس: ${data.address}${data.city ? ` — ${data.city}` : ""}${data.province ? `، ${data.province}` : ""}` : "", ].filter(Boolean); for (const line of info) { ctx.fillText(line, W - pad, y); y += 20; } y += 10; const cols = [ { key: "type", w: colType, label: "نوع محصول" }, { key: "qty", w: colQty, label: "عدد" }, { key: "price", w: colPrice, label: "قیمت" }, { key: "img", w: colImg, label: "عکس" }, ]; function colXs() { let right = W - pad; return cols.map((c) => { const x0 = right - c.w; right = x0; return { ...c, x: x0 }; }); } const headerCols = colXs(); ctx.fillStyle = PALETTE.blush; ctx.fillRect(pad, y, W - pad * 2, tableHeadH); ctx.strokeStyle = PALETTE.cardBorder; ctx.lineWidth = 1; ctx.strokeRect(pad, y, W - pad * 2, tableHeadH); ctx.fillStyle = PALETTE.berry; ctx.font = `700 12px ${font}`; for (const c of headerCols) { ctx.strokeRect(c.x, y, c.w, tableHeadH); ctx.textAlign = "center"; ctx.fillText(c.label, c.x + c.w / 2, y + 11); } y += tableHeadH; for (let i = 0; i < data.items.length; i++) { const item = data.items[i]; const rh = rowHeights[i]; const rowCols = colXs(); ctx.fillStyle = i % 2 === 0 ? PALETTE.white : PALETTE.soft; ctx.fillRect(pad, y, W - pad * 2, rh); ctx.strokeStyle = PALETTE.cardBorder; ctx.strokeRect(pad, y, W - pad * 2, rh); for (const c of rowCols) ctx.strokeRect(c.x, y, c.w, rh); const typeCol = rowCols.find((c) => c.key === "type"); const qtyCol = rowCols.find((c) => c.key === "qty"); const priceCol = rowCols.find((c) => c.key === "price"); const imgCol = rowCols.find((c) => c.key === "img"); ctx.fillStyle = PALETTE.ink; ctx.font = `700 12px ${font}`; ctx.textAlign = "right"; const type = item.category || "—"; let ty = y + rowPad; for (const line of wrapText(ctx, type, colType - 16)) { ctx.fillText(line, typeCol.x + typeCol.w - 8, ty); ty += 16; } ctx.font = `400 11px ${font}`; ctx.fillStyle = PALETTE.muted; for (const line of wrapText(ctx, item.name || "", colType - 16)) { ctx.fillText(line, typeCol.x + typeCol.w - 8, ty); ty += 15; } ctx.fillStyle = PALETTE.ink; ctx.font = `500 13px ${font}`; ctx.textAlign = "center"; ctx.fillText(formatFaInt(item.quantity), qtyCol.x + qtyCol.w / 2, y + rh / 2 - 7); ctx.fillText( data.formatPrice(item.price * item.quantity), priceCol.x + priceCol.w / 2, y + rh / 2 - 7 ); const img = images[i]; const ix = imgCol.x + (imgCol.w - thumbW) / 2; const iy = y + (rh - thumbH) / 2; if (img) { ctx.drawImage(img, ix, iy, thumbW, thumbH); ctx.strokeStyle = PALETTE.cardBorder; ctx.strokeRect(ix, iy, thumbW, thumbH); } else { ctx.fillStyle = PALETTE.blush; ctx.fillRect(ix, iy, thumbW, thumbH); } y += rh; } y += 14; ctx.strokeStyle = PALETTE.gold; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(W - pad, y); ctx.stroke(); y += 14; ctx.fillStyle = PALETTE.berry; ctx.font = `700 15px ${font}`; ctx.textAlign = "right"; ctx.fillText("جمع کل", W - pad, y); ctx.textAlign = "left"; ctx.direction = "ltr"; ctx.fillText(data.totalLabel, pad, y); ctx.direction = "rtl"; const eurRate = typeof data.eurToTomanRate === "number" && Number.isFinite(data.eurToTomanRate) && data.eurToTomanRate > 0 ? data.eurToTomanRate : 0; if (eurRate > 0) { y += 26; ctx.fillStyle = PALETTE.muted; ctx.font = `400 12px ${font}`; ctx.textAlign = "right"; const rateLabel = `نرخ یورو: ${data.formatPrice(eurRate)}`; ctx.fillText(rateLabel, W - pad, y); } const jpegFull = await canvasToJpegBytes(canvas, 0.92); const pageCssH = Math.floor((W * 841.89) / 595.28); const pages = []; const logoLinkJpeg = { x: logoHit.x * scale, y: logoHit.y * scale, w: logoHit.w * scale, h: logoHit.h * scale, url: SITE_URL, }; if (H <= pageCssH + 40) { pages.push({ bytes: jpegFull, w: canvas.width, h: canvas.height, links: [logoLinkJpeg], }); } else { let sy = 0; let pageIndex = 0; while (sy < H) { const sliceH = Math.min(pageCssH, H - sy); const slice = document.createElement("canvas"); slice.width = canvas.width; slice.height = Math.ceil(sliceH * scale); const sctx = slice.getContext("2d"); if (!sctx) break; sctx.drawImage( canvas, 0, Math.floor(sy * scale), canvas.width, slice.height, 0, 0, slice.width, slice.height ); const pageLinks = pageIndex === 0 && logoHit.y + logoHit.h <= sliceH ? [logoLinkJpeg] : []; pages.push({ bytes: await canvasToJpegBytes(slice, 0.92), w: slice.width, h: slice.height, links: pageLinks, }); sy += sliceH; pageIndex += 1; } } const blob = jpegsToPdfBlob(pages); return new File([blob], `kikomiko-invoice-${data.orderId}.pdf`, { type: "application/pdf", lastModified: Date.now(), }); } export function downloadFile(file) { const url = URL.createObjectURL(file); const a = document.createElement("a"); a.href = url; a.download = file.name; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } export function whatsappChatUrl(waNumber, text) { const digits = String(waNumber || "").replace(/\D/g, ""); return `https://wa.me/${digits}?text=${encodeURIComponent(text || "")}`; } /** Upload PDF to Worker; returns public URL for WhatsApp (no client download). */ export async function uploadInvoicePdf(file) { const res = await fetch("/api/invoices", { method: "POST", headers: { "content-type": "application/pdf" }, body: file, }); const json = await res.json().catch(() => ({})); if (!res.ok || !json.ok || !json.url) { throw new Error(json.error || "آپلود فاکتور ناموفق بود"); } return String(json.url); } /** * Host PDF, open WhatsApp to store with invoice link (no client download). */ export async function openWhatsAppWithInvoice(file, waText, waNumber) { const pdfUrl = await uploadInvoicePdf(file); const note = [waText || "", "", "📄 Invoice PDF:", pdfUrl] .filter((line, i, arr) => !(line === "" && arr[i - 1] === "")) .join("\n") .trim(); const url = whatsappChatUrl(waNumber, note); window.location.href = url; return { pdfUrl, waUrl: url }; }