Files
meezi/web/dashboard/src/lib/thermal-print.ts
T
2026-05-30 09:42:32 +03:30

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* thermal-print.ts
*
* Generates a self-contained 80 mm HTML document and fires it through a
* hidden <iframe> so the browser's OS print spooler routes the job to the
* default printer (e.g. Atom A300 USB driver).
*
* Why iframe instead of window.print() on the main page?
* The iframe document contains ONLY the receipt, so @page height = content
* height. No blank A4 tail after the receipt.
* `html { direction: rtl }` is set unconditionally inside the iframe.
* The main app's dir attribute never bleeds in.
* No `visibility: hidden` tricks needed — the print area IS the document.
*/
export type ThermalLine = {
name: string;
quantity: number;
price?: string;
notes?: string;
};
export type ThermalTotals = {
subtotal?: string;
discount?: string;
tax?: string;
total: string;
payments?: { method: string; amount: string }[];
};
export type ThermalSlipData = {
cafeName: string;
/** "فیش آشپزخانه" or "صورتحساب مشتری" */
title: string;
date: string;
/** Pre-formatted row, e.g. "میز: ۳ | سفارش: #۱۰۵" */
metaRow?: string;
lines: ThermalLine[];
totals?: ThermalTotals;
footer?: string;
/** 'fa' (default) → RTL, 'en' → LTR */
locale?: string;
/** Optional café logo URL — rendered centered above the name for branding. */
logoUrl?: string;
/** Optional tagline / address line under the café name. */
tagline?: string;
/** Optional custom header note (from branch print settings) shown under the name. */
header?: string;
/** Optional WiFi password line printed near the footer. */
wifi?: string;
/** Paper width in mm — 58 or 80 (default 80). Controls page width + scale. */
paperWidthMm?: number;
};
/** Absolute URL to the bundled Vazirmatn web-font (same origin). */
function vazirmatnFontUrl(): string {
const origin =
typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/fonts/Vazirmatn-Variable.woff2`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Escapes a string for safe injection into an HTML template. */
function esc(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ─────────────────────────────────────────────────────────────────────────────
// Document builder
// ─────────────────────────────────────────────────────────────────────────────
export function buildThermalDocument(data: ThermalSlipData): string {
const isRtl = (data.locale ?? "fa") !== "en";
const dir = isRtl ? "rtl" : "ltr";
const lang = data.locale ?? "fa";
const linesHtml = data.lines
.map((l) => {
const notePart = l.notes ? ` (${esc(l.notes)})` : "";
return `<div class="row">
<span>${esc(l.name)} × ${l.quantity}${notePart}</span>
${l.price ? `<span class="price">${esc(l.price)}</span>` : ""}
</div>`;
})
.join("\n");
let totalsHtml = "";
if (data.totals) {
const tt = data.totals;
const subtotalLabel = isRtl ? "جمع اقلام" : "Subtotal";
const discountLabel = isRtl ? "تخفیف" : "Discount";
const taxLabel = isRtl ? "مالیات (۹٪)" : "Tax (9%)";
const totalLabel = isRtl ? "مجموع" : "Total";
totalsHtml = `
<hr class="dashed">
${tt.subtotal ? `<div class="row sm"><span>${subtotalLabel}</span><span>${esc(tt.subtotal)}</span></div>` : ""}
${tt.discount ? `<div class="row sm"><span>${discountLabel}</span><span>- ${esc(tt.discount)}</span></div>` : ""}
${tt.tax ? `<div class="row sm"><span>${taxLabel}</span><span>${esc(tt.tax)}</span></div>` : ""}
<div class="row total"><span>${totalLabel}</span><span>${esc(tt.total)}</span></div>
${(tt.payments ?? [])
.map((p) => `<div class="row sm mt1"><span>${esc(p.method)}</span><span>${esc(p.amount)}</span></div>`)
.join("")}
`;
}
const wifiLabel = isRtl ? "وای‌فای" : "WiFi";
const footerInner = [
data.wifi ? `<div class="center sm">${wifiLabel}: ${esc(data.wifi)}</div>` : "",
data.footer ? `<div class="center sm muted">${esc(data.footer)}</div>` : "",
]
.filter(Boolean)
.join("");
const footerHtml = footerInner ? `<hr class="dashed">${footerInner}` : "";
const logoHtml = data.logoUrl
? `<img class="logo" src="${esc(data.logoUrl)}" alt="">`
: "";
const taglineHtml = data.tagline
? `<div class="center tagline">${esc(data.tagline)}</div>`
: "";
const headerHtml = data.header
? `<div class="center header-note">${esc(data.header)}</div>`
: "";
// Paper width: 58 mm or 80 mm. Narrower paper gets a slightly smaller base
// font so lines don't wrap awkwardly.
const widthMm = data.paperWidthMm === 58 ? 58 : 80;
const baseFontPt = widthMm === 58 ? 10 : 11.5;
const wrapPadMm = widthMm === 58 ? "3mm 3mm 5mm" : "4mm 4.5mm 6mm";
const fontUrl = vazirmatnFontUrl();
return `<!DOCTYPE html>
<html dir="${dir}" lang="${lang}">
<head>
<meta charset="utf-8">
<style>
/* ── Web-font: bundled Vazirmatn variable ─────────────────── */
@font-face {
font-family: 'Vazirmatn';
src: url('${fontUrl}') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: block;
}
/* ── Page ─────────────────────────────────────────────────── */
@page {
/* width tracks the configured paper; height tracks content — no blank tail */
size: ${widthMm}mm auto;
margin: 0;
}
/* ── Reset ────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Root ─────────────────────────────────────────────────── */
html, body {
width: ${widthMm}mm;
direction: ${dir};
font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif;
font-size: ${baseFontPt}pt;
font-weight: 500;
line-height: 1.55;
color: #000;
background: #fff;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ── Wrapper ──────────────────────────────────────────────── */
.wrap { padding: ${wrapPadMm}; }
/* ── Branding header ──────────────────────────────────────── */
.logo {
display: block;
max-width: 36mm;
max-height: 18mm;
margin: 0 auto 1.5mm;
object-fit: contain;
}
.cafe-name {
text-align: center;
font-size: 16pt;
font-weight: 800;
letter-spacing: -0.2px;
line-height: 1.25;
}
.tagline {
font-size: 8.5pt;
font-weight: 500;
color: #444;
margin-top: 0.5mm;
}
.header-note {
font-size: 9pt;
font-weight: 600;
color: #222;
margin-top: 1mm;
white-space: pre-line;
}
.doc-title {
text-align: center;
font-size: 10pt;
font-weight: 700;
margin-top: 1.5mm;
padding: 0.8mm 0;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
}
.date {
text-align: center;
font-size: 8.5pt;
color: #444;
margin-top: 1.5mm;
}
.meta {
font-size: 9pt;
font-weight: 600;
margin-top: 1.5mm;
text-align: center;
color: #222;
}
/* ── Text helpers ─────────────────────────────────────────── */
.center { text-align: center; }
.bold { font-weight: 700; }
.sm { font-size: 9.5pt; }
.muted { color: #555; }
.mt1 { margin-top: 1mm; }
.mt2 { margin-top: 2mm; }
/* ── Divider ──────────────────────────────────────────────── */
hr.dashed { border: none; border-top: 1px dashed #000; margin: 2.5mm 0; }
hr.solid { border: none; border-top: 1px solid #000; margin: 2.5mm 0; }
/* ── Rows ─────────────────────────────────────────────────── */
.row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 2mm;
line-height: 1.7;
}
.row > span:first-child { flex: 1; }
.row .price { white-space: nowrap; font-weight: 700; font-variant-numeric: tabular-nums; }
.row.sm { color: #333; }
/* ── Totals ───────────────────────────────────────────────── */
.total {
font-size: 14pt;
font-weight: 800;
margin-top: 1.5mm;
padding-top: 1.5mm;
border-top: 2px solid #000;
}
.total .price { font-size: 14pt; }
</style>
</head>
<body>
<div class="wrap">
${logoHtml}
<div class="cafe-name">${esc(data.cafeName)}</div>
${taglineHtml}
${headerHtml}
<div class="doc-title">${esc(data.title)}</div>
<div class="date">${esc(data.date)}</div>
${data.metaRow ? `<div class="meta">${esc(data.metaRow)}</div>` : ""}
<hr class="dashed">
${linesHtml}
${totalsHtml}
${footerHtml}
</div>
</body>
</html>`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Print trigger
// ─────────────────────────────────────────────────────────────────────────────
const IFRAME_ID = "meezi-thermal-print-frame";
/**
* Injects (or reuses) a hidden <iframe>, writes the thermal HTML document
* into it, then calls `iframe.contentWindow.print()`.
*
* The iframe is positioned off-screen so it is invisible but still mounted
* in the DOM — required for Chrome/Edge to render it before printing.
*/
export function printThermal(html: string): void {
if (typeof window === "undefined") return;
let frame = document.getElementById(IFRAME_ID) as HTMLIFrameElement | null;
if (!frame) {
frame = document.createElement("iframe");
frame.id = IFRAME_ID;
frame.setAttribute("aria-hidden", "true");
frame.setAttribute("tabindex", "-1");
frame.style.cssText =
"position:fixed;top:-9999px;left:-9999px;width:80mm;height:1px;" +
"border:0;opacity:0;pointer-events:none;";
document.body.appendChild(frame);
}
const doc = frame.contentDocument ?? frame.contentWindow?.document;
if (!doc) return;
doc.open();
doc.write(html);
doc.close();
const fire = () => {
try {
frame!.contentWindow?.focus();
frame!.contentWindow?.print();
} catch {
// Safari throws if the frame was cross-origin — shouldn't happen for
// same-origin same-page iframes, but guard just in case.
}
};
// Wait for the embedded Vazirmatn web-font to finish loading inside the
// iframe before printing — otherwise the spooler may rasterise the receipt
// with a fallback font. `document.fonts.ready` resolves once @font-face
// loads complete; we cap the wait so a font failure never blocks printing.
const win = frame.contentWindow;
const fontSet = win?.document?.fonts;
if (fontSet) {
let printed = false;
const run = () => {
if (printed) return;
printed = true;
requestAnimationFrame(fire);
};
fontSet.ready.then(run).catch(run);
// Safety timeout in case fonts.ready never settles.
setTimeout(run, 1500);
} else {
requestAnimationFrame(fire);
}
}