From 22f118afdfc2073cd66bd01b8bece9ea0d7f62c3 Mon Sep 17 00:00:00 2001 From: NotAbdelrahmanelsayed Date: Sat, 26 Sep 2026 18:36:07 +0200 Subject: [PATCH 1/2] feat(pos): copy invoice as image from invoice history Adds a "Copy as Image" button next to Print / Create Return in the Invoice History dialog. The invoice is rendered from the same /printview page the Print button opens (built-in receipt for offline invoices) in a hidden iframe, rasterized with html-to-image, and written to the clipboard as PNG. When the clipboard is unavailable (http, permission denied) it downloads .jpg instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XwETwvYEMLVxyZ79tn7czZ --- POS/package.json | 1 + .../components/sale/InvoiceHistoryDialog.vue | 37 +++- POS/src/utils/invoiceImage.js | 180 ++++++++++++++++++ POS/src/utils/printInvoice.js | 6 +- pos_next/translations/ar.csv | 7 + 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 POS/src/utils/invoiceImage.js diff --git a/POS/package.json b/POS/package.json index daeaf5ec4..836e22bd9 100644 --- a/POS/package.json +++ b/POS/package.json @@ -25,6 +25,7 @@ "dexie": "^4.2.0", "feather-icons": "^4.29.2", "frappe-ui": "^0.1.240", + "html-to-image": "^1.11.13", "pinia": "^3.0.4", "qz-tray": "^2.2.5", "socket.io-client": "^4.7.2", diff --git a/POS/src/components/sale/InvoiceHistoryDialog.vue b/POS/src/components/sale/InvoiceHistoryDialog.vue index bcf7ab638..a62ddcf94 100644 --- a/POS/src/components/sale/InvoiceHistoryDialog.vue +++ b/POS/src/components/sale/InvoiceHistoryDialog.vue @@ -130,6 +130,19 @@ + @@ -167,11 +180,12 @@ import { useFormatters } from "@/composables/useFormatters" import { useToast } from "@/composables/useToast" import { DEFAULT_CURRENCY, DEFAULT_LOCALE, formatCurrency as formatCurrencyUtil } from "@/utils/currency" import { getInvoiceStatusColor } from "@/utils/invoice" +import { copyInvoiceImage } from "@/utils/invoiceImage" import { Button, Dialog, Input, createResource } from "frappe-ui" import { computed, ref, watch } from "vue" import ReturnInvoiceDialog from "./ReturnInvoiceDialog.vue" -const { showError } = useToast(); +const { showError, showSuccess, showInfo } = useToast(); const { formatDate, formatTime } = useFormatters(); const props = defineProps({ @@ -336,6 +350,27 @@ function canCreateReturn(invoice) { return invoice.docstatus === 1 && !invoice.is_return && invoice.status !== 'Credit Note Issued' } +// Name of the invoice whose image is being rendered +const copyingInvoice = ref(null); + +async function copyAsImage(invoice) { + if (copyingInvoice.value) return; + copyingInvoice.value = invoice.name; + try { + const { method } = await copyInvoiceImage(invoice); + if (method === "clipboard") { + showSuccess(__("Invoice image copied")); + } else { + showInfo(__("Clipboard unavailable, invoice image downloaded")); + } + } catch (error) { + console.error("Error copying invoice image:", error); + showError(error?.message || __("Failed to create invoice image")); + } finally { + copyingInvoice.value = null; + } +} + function openReturnModal(invoice) { selectedInvoiceForReturn.value = invoice; showReturnDialog.value = true; diff --git a/POS/src/utils/invoiceImage.js b/POS/src/utils/invoiceImage.js new file mode 100644 index 000000000..fd79e3e3d --- /dev/null +++ b/POS/src/utils/invoiceImage.js @@ -0,0 +1,180 @@ +import { logger } from "@/utils/logger" +import { + buildReceiptDocumentHTML, + hydrateLocalOnlyInvoice, + isLocalOnlyInvoiceName, + resolvePrintSettings, +} from "@/utils/printInvoice" +import { toCanvas } from "html-to-image" + +const log = logger.create("InvoiceImage") + +const FRAME_WIDTH_PX = 420 +const FRAME_LOAD_TIMEOUT_MS = 20000 + +/** + * Describe what to render for an invoice: the same /printview page the Print + * button opens, or the built-in receipt for offline/local-only invoices. + */ +async function resolveInvoiceSource(invoiceData) { + const invoice = await hydrateLocalOnlyInvoice(invoiceData) + + if (isLocalOnlyInvoiceName(invoice.name)) { + if (!invoice.items?.length) { + throw new Error( + __( + "This offline receipt is no longer in browser storage. Sync the invoice, then try again from history.", + ), + ) + } + return { + srcdoc: buildReceiptDocumentHTML(invoice), + selector: ".receipt", + } + } + + const settings = await resolvePrintSettings( + invoice.pos_profile, + invoice.print_format, + null, + ) + + const params = new URLSearchParams({ + doctype: invoice.doctype || "Sales Invoice", + name: invoice.name, + format: settings.printFormat, + no_letterhead: settings.letterhead ? 0 : 1, + _lang: "en", + _t: Date.now(), + }) + if (settings.letterhead) params.append("letterhead", settings.letterhead) + + return { src: `/printview?${params}`, selector: ".print-format" } +} + +function loadFrame({ src, srcdoc }) { + const frame = document.createElement("iframe") + frame.setAttribute("aria-hidden", "true") + frame.tabIndex = -1 + Object.assign(frame.style, { + position: "fixed", + left: "-10000px", + top: "0", + width: `${FRAME_WIDTH_PX}px`, + height: "100px", + border: "0", + visibility: "hidden", + }) + + const loaded = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(__("Timed out loading invoice"))), + FRAME_LOAD_TIMEOUT_MS, + ) + frame.onload = () => { + clearTimeout(timer) + resolve(frame) + } + frame.onerror = () => { + clearTimeout(timer) + reject(new Error(__("Failed to load invoice"))) + } + }) + + if (srcdoc !== undefined) frame.srcdoc = srcdoc + else frame.src = src + document.body.appendChild(frame) + return { frame, loaded } +} + +async function waitForAssets(doc) { + const images = Array.from(doc.images).filter((img) => !img.complete) + await Promise.all([ + doc.fonts?.ready, + ...images.map( + (img) => + new Promise((resolve) => { + img.onload = resolve + img.onerror = resolve + }), + ), + ]) +} + +/** + * Render an invoice to an image blob ("image/png" or "image/jpeg"). + */ +export async function renderInvoiceImage(invoiceData, type = "image/png") { + const source = await resolveInvoiceSource(invoiceData) + const { frame, loaded } = loadFrame(source) + try { + await loaded + const doc = frame.contentDocument + if (!doc?.body) throw new Error(__("Failed to load invoice")) + await waitForAssets(doc) + + const target = doc.querySelector(source.selector) || doc.body + // Frappe's screen styles give .print-format an A4 min-height, a drop + // shadow and margins. The image size is measured from this node, so + // strip them on the (throwaway) original rather than the clone. + Object.assign(target.style, { + minHeight: "0", + boxShadow: "none", + margin: "0", + }) + // Size the frame to the content so nothing is clipped. + frame.style.height = `${doc.documentElement.scrollHeight}px` + + const canvas = await toCanvas(target, { + pixelRatio: 2, + backgroundColor: "#ffffff", + }) + // Encode here: html-to-image's toBlob ignores `type` and always emits PNG. + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, type, 0.92), + ) + if (!blob) throw new Error(__("Failed to create invoice image")) + return blob + } finally { + frame.remove() + } +} + +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +/** + * Copy the invoice image to the clipboard as PNG (the only image type browsers + * accept there). Falls back to downloading a JPEG when the clipboard is + * unavailable, e.g. over plain http or when permission is denied. + * @returns {Promise<{method: "clipboard" | "download"}>} + */ +export async function copyInvoiceImage(invoiceData) { + if (navigator.clipboard?.write && window.ClipboardItem) { + // Pass the blob promise straight to ClipboardItem so Safari keeps the + // user-gesture context across the async render. + const pngPromise = renderInvoiceImage(invoiceData, "image/png") + try { + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": pngPromise }), + ]) + return { method: "clipboard" } + } catch (err) { + // A render failure would fail the download too — surface it. + await pngPromise + log.warn("Clipboard write failed, downloading instead:", err) + } + } + + const jpeg = await renderInvoiceImage(invoiceData, "image/jpeg") + downloadBlob(jpeg, `${invoiceData.name}.jpg`) + return { method: "download" } +} diff --git a/POS/src/utils/printInvoice.js b/POS/src/utils/printInvoice.js index 6cd3f37e7..d5059aabb 100644 --- a/POS/src/utils/printInvoice.js +++ b/POS/src/utils/printInvoice.js @@ -7,7 +7,7 @@ import { printHTML as qzPrintHTML } from "@/utils/qzTray"; const log = logger.create("PrintInvoice"); -const DEFAULT_PRINT_FORMAT = "POS Next Receipt"; +export const DEFAULT_PRINT_FORMAT = "POS Next Receipt"; // ============================================================================ // Shared helpers @@ -340,7 +340,7 @@ export function buildReceiptHTML(invoiceData) { `; } -function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) { +export function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) { const controls = includeControls ? `
@@ -371,7 +371,7 @@ function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) * Resolve print format & letterhead from a POS Profile. * Returns defaults when the profile lookup fails so callers always get a value. */ -async function resolvePrintSettings(posProfile, printFormat, letterhead) { +export async function resolvePrintSettings(posProfile, printFormat, letterhead) { if (printFormat) return { printFormat, letterhead }; if (posProfile) { diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv index 2c919c1ab..298fd7ec3 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1680,3 +1680,10 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "Expense {0} was recorded, but attachments failed.","تم تسجيل المصروف {0}، لكن فشل إرفاق الملفات.","" "Mode of Payment","طريقة الدفع","" "Submit","إرسال","" +"Copy as Image","نسخ كصورة","" +"Invoice image copied","تم نسخ صورة الفاتورة","" +"Clipboard unavailable, invoice image downloaded","الحافظة غير متاحة، تم تنزيل صورة الفاتورة","" +"Failed to create invoice image","فشل إنشاء صورة الفاتورة","" +"Timed out loading invoice","انتهت مهلة تحميل الفاتورة","" +"Failed to load invoice","فشل تحميل الفاتورة","" +"This offline receipt is no longer in browser storage. Sync the invoice, then try again from history.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم أعد المحاولة من السجل.","" From a8dd11cb08e6a9124a586485ff5457f14e054b3f Mon Sep 17 00:00:00 2001 From: NotAbdelrahmanelsayed Date: Sat, 26 Sep 2026 19:00:02 +0200 Subject: [PATCH 2/2] fix(pos): resolve print format when invoice data lacks pos_profile Invoice rows from some list endpoints omit pos_profile, which made the image fall back to the default print format instead of the profile's. Look the profile up from the Sales Invoice when it's missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XwETwvYEMLVxyZ79tn7czZ --- POS/src/utils/invoiceImage.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/POS/src/utils/invoiceImage.js b/POS/src/utils/invoiceImage.js index fd79e3e3d..c0405ffd5 100644 --- a/POS/src/utils/invoiceImage.js +++ b/POS/src/utils/invoiceImage.js @@ -1,3 +1,4 @@ +import { call } from "@/utils/apiWrapper" import { logger } from "@/utils/logger" import { buildReceiptDocumentHTML, @@ -33,14 +34,30 @@ async function resolveInvoiceSource(invoiceData) { } } + const doctype = invoice.doctype || "Sales Invoice" + // Some invoice lists omit pos_profile; without it the + // profile's print format would silently fall back to the default. + let posProfile = invoice.pos_profile + if (!posProfile) { + try { + posProfile = await call("frappe.client.get_value", { + doctype, + filters: { name: invoice.name }, + fieldname: "pos_profile", + }).then((r) => (r?.message || r)?.pos_profile) + } catch (err) { + log.warn("Could not fetch invoice POS Profile:", err) + } + } + const settings = await resolvePrintSettings( - invoice.pos_profile, + posProfile, invoice.print_format, null, ) const params = new URLSearchParams({ - doctype: invoice.doctype || "Sales Invoice", + doctype, name: invoice.name, format: settings.printFormat, no_letterhead: settings.letterhead ? 0 : 1,