diff --git a/POS/package.json b/POS/package.json
index fd9c9b807..d9bbec4ff 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",
"reka-ui": "^2.5.0",
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..c0405ffd5
--- /dev/null
+++ b/POS/src/utils/invoiceImage.js
@@ -0,0 +1,197 @@
+import { call } from "@/utils/apiWrapper"
+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 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(
+ posProfile,
+ invoice.print_format,
+ null,
+ )
+
+ const params = new URLSearchParams({
+ doctype,
+ 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 c74b75092..46f864968 100644
--- a/POS/src/utils/printInvoice.js
+++ b/POS/src/utils/printInvoice.js
@@ -8,7 +8,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
@@ -347,7 +347,7 @@ export function buildReceiptHTML(invoiceData) {
`;
}
-function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) {
+export function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) {
const controls = includeControls
? `
@@ -378,7 +378,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 85bef47f5..65accaee6 100644
--- a/pos_next/translations/ar.csv
+++ b/pos_next/translations/ar.csv
@@ -1680,6 +1680,13 @@ 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.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم أعد المحاولة من السجل.",""
"Over limit by {0}","تجاوز الحد بمقدار {0}",""
"{0} left","المتبقي {0}",""
"Expense","مصروف",""