PortablePosPrinterHamekara/server.js

641 lines
24 KiB
JavaScript
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.

// server.js — HILPOS TT200 LAN printer bridge
// HTTP : 9091 | HTTPS : 9092
// Pipeline: HTML (RTL/Persian) -> PNG (puppeteer-core) -> 1-bit bitmap -> ESC/POS raster -> TCP to printer:9100
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const net = require('net');
const path = require('path');
const os = require('os');
const https = require('https');
const { PNG } = require('pngjs');
const puppeteer = require('puppeteer-core');
const sharp = require('sharp');
const bmpjs = require('bmp-js');
const escpos = require('@node-escpos/core');
const Network = require('@node-escpos/network-adapter');
const app = express();
/* ------------------------ CORS / PNA ------------------------ */
// Exact origins only (no wildcard) to satisfy Chrome PNA from HTTPS pages
const ALLOWED_ORIGINS = new Set([
'https://hamekara.com',
'https://www.hamekara.com',
// add more if needed:
// 'https://staging.hamekara.com',
]);
app.use(express.json({ limit: '8mb' }));
app.use((req, res, next) => {
const origin = req.headers.origin || '';
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
// Key header that allows HTTPS page -> local network request
res.setHeader('Access-Control-Allow-Private-Network', 'true');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.use(
cors({
origin: (origin, cb) => {
if (!origin) return cb(null, false);
return cb(null, ALLOWED_ORIGINS.has(origin) ? origin : false);
},
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: false,
})
);
/* ------------------------ Utils ------------------------ */
const OUT_DIR = path.join(__dirname, 'out');
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR);
function lanIPs() {
const nets = os.networkInterfaces();
const out = [];
for (const name of Object.keys(nets)) {
for (const n of nets[name] || []) {
if (n.family === 'IPv4' && !n.internal) out.push(n.address);
}
}
return out;
}
/* ------------------------ Chrome launcher ------------------------ */
let browserPromise = null;
function findChromeExecutable() {
// Env var wins
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
if (envPath && fs.existsSync(envPath)) return envPath;
// Common Windows locations; include Edge as fallback
const candidates = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe'),
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
];
for (const p of candidates) if (p && fs.existsSync(p)) return p;
throw new Error('No Chromium browser found. Install Chrome/Edge or set PUPPETEER_EXECUTABLE_PATH');
}
async function getBrowser() {
if (!browserPromise) {
const exe = findChromeExecutable();
browserPromise = puppeteer.launch({
headless: true,
executablePath: exe,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--font-render-hinting=medium',
'--force-color-profile=srgb',
],
});
}
return browserPromise;
}
/* ------------------------ HTML -> PNG (BUFFER) ------------------------ */
async function renderHtmlToPngBuffer(html, widthPx = 576) {
const browser = await getBrowser();
const page = await browser.newPage();
await page.setViewport({
width: Number(widthPx) || 576,
height: 1000, // will auto-extend via fullPage screenshot
deviceScaleFactor: 1,
});
await page.emulateMediaType('screen');
await page.setContent(html, { waitUntil: ['domcontentloaded', 'networkidle0'] });
// Let layout settle a frame or two
await page.evaluate(
() =>
new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r))
)
);
const buffer = await page.screenshot({
type: 'png',
fullPage: true,
omitBackground: false, // ensure white background
});
await page.close();
return buffer;
}
// Build ESC * (24-dot double-density) bit image from mono map
function buildEscposBitImage_ESCStar({ mono, width, height, cut = true }) {
const ESC = 0x1B, GS = 0x1D;
const m = 33; // 24-dot double density
const bytesPerCol = Math.ceil(width / 8);
const out = [];
// Init
out.push(Buffer.from([ESC, 0x40])); // ESC @
for (let bandY = 0; bandY < height; bandY += 24) {
// ESC * m nL nH
const nL = bytesPerCol & 0xFF;
const nH = (bytesPerCol >> 8) & 0xFF;
out.push(Buffer.from([ESC, 0x2A, m, nL, nH]));
// For each horizontal byte (8 px)
for (let xb = 0; xb < bytesPerCol; xb++) {
// For each of 24 rows -> 3 bytes vertical
for (let k = 0; k < 3; k++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const x = xb * 8 + bit;
const y = bandY + k * 8 + 0; // base inside this 8-row slice
const yy = bandY + k * 8 + bit; // vertical bit (one bit per row)
const yyClamped = bandY + k * 8 + bit;
const inRange = (x < width) && (yyClamped < height);
const ink = inRange ? (mono[yyClamped * width + x] & 1) : 0; // 1=black
byte |= (ink << (7 - bit));
}
out.push(Buffer.from([byte]));
}
}
// line feed after each 24-dot band
out.push(Buffer.from([0x0A]));
}
// Feed + partial cut
out.push(Buffer.from([0x1B, 0x64, 0x03])); // ESC d 3
if (cut) out.push(Buffer.from([GS, 0x56, 0x42, 0x03])); // GS V 66 3
return Buffer.concat(out);
}
/* ------------------------ IMAGE -> 1-bit + ESC/POS ------------------------ */
// Convert an RGBA/greyscale image buffer to 1bpp bitmap + ESC/POS packed bytes
// Convert any image buffer -> 1bpp mono + packed rows + BMP preview
async function imageToMonochrome({ inputBuffer, widthLimitPx = 576, threshold = 235, dither = true }) {
const s = sharp(inputBuffer).resize({ width: widthLimitPx, withoutEnlargement: true }).greyscale();
const { data: gray, info } = await s.raw().toBuffer({ resolveWithObject: true });
const { width, height } = info;
const mono = new Uint8Array(width * height); // 1 = black ink, 0 = white
const err = dither ? new Float32Array(gray) : null;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const oldP = dither ? err[i] : gray[i];
const black = oldP < threshold ? 1 : 0;
mono[i] = black;
if (dither) {
const newP = black ? 0 : 255;
const qe = oldP - newP;
if (x + 1 < width) err[i + 1] += qe * 7 / 16;
if (y + 1 < height && x > 0) err[i + width - 1] += qe * 3 / 16;
if (y + 1 < height) err[i + width] += qe * 5 / 16;
if (y + 1 < height && x + 1 < width) err[i + width + 1] += qe * 1 / 16;
}
}
}
// Pack mono (horizontal) for GS v 0
const rowBytes = Math.ceil(width / 8);
const packed = Buffer.alloc(rowBytes * height);
let nonwhite = 0;
for (let y = 0; y < height; y++) {
for (let xb = 0; xb < rowBytes; xb++) {
let byte = 0;
for (let b = 0; b < 8; b++) {
const x = xb * 8 + b;
if (x < width) {
const bit = mono[y * width + x] & 1; // 1 = black
byte |= bit << (7 - b);
nonwhite += bit;
}
}
packed[y * rowBytes + xb] = byte;
}
}
// BMP preview (for browser)
const rgba = Buffer.alloc(width * height * 4);
for (let i = 0; i < mono.length; i++) {
const v = mono[i] ? 0 : 255; // black ink -> black pixel
const o = i * 4;
rgba[o] = v; rgba[o + 1] = v; rgba[o + 2] = v; rgba[o + 3] = 255;
}
const bmp = bmpjs.encode({ width, height, data: rgba });
return { width, height, rowBytes, packed, mono, approxNonWhite: nonwhite, bmpBuffer: bmp.data };
}
// Build ESC/POS GS v 0 (raster bit image) command block from packed rows
function buildEscposRasterGSv0({ packed, width, height, rowBytes, cut = true }) {
const ESC = Buffer.from([0x1B]);
const GS = Buffer.from([0x1D]);
const m = 0; // normal mode
const xL = rowBytes & 0xFF;
const xH = (rowBytes >> 8) & 0xFF;
const yL = height & 0xFF;
const yH = (height >> 8) & 0xFF;
const lf2 = Buffer.from([0x0A, 0x0A]); // \n\n as bytes
const cutCmd = Buffer.from([0x1D, 0x56, 0x42, 0x03]); // GS V 66 3 (partial cut)
const init = Buffer.from([0x1B, 0x40]); // ESC @
const header = Buffer.from([0x1D, 0x76, 0x30, m, xL, xH, yL, yH]); // GS v 0 m xL xH yL yH
return Buffer.concat([
init,
header,
packed,
lf2,
...(cut ? [cutCmd] : []),
]);
}
// --- helper: TCP send ---
function sendRawToTcp({ host, port, data, timeoutMs = 8000 }) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port }, () => {
socket.write(data);
socket.end(); // half-close after write
});
socket.on('error', reject);
socket.setTimeout(timeoutMs, () => {
socket.destroy(new Error('Printer connection timeout'));
});
socket.on('close', resolve);
});
}
// --- helper: PNG buffer -> ESC/POS raster (GS v 0) ---
async function pngBufferToEscposRaster(pngBuffer, threshold = 210, invert = false) {
// grayscale raw pixels
const { data, info } = await sharp(pngBuffer)
.ensureAlpha() // in case PNG has alpha
.removeAlpha()
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const widthBytes = Math.ceil(width / 8);
// pack 1-bit rows
const raster = Buffer.alloc(widthBytes * height);
for (let y = 0; y < height; y++) {
const rowOffset = y * width;
const outRow = y * widthBytes;
for (let xByte = 0; xByte < widthBytes; xByte++) {
let b = 0;
for (let bit = 0; bit < 8; bit++) {
const x = xByte * 8 + bit;
const inRange = x < width;
const idx = rowOffset + (inRange ? x : width - 1);
const gray = data[idx]; // 0..255
let isBlack = gray < threshold;
if (invert) isBlack = !isBlack;
b |= (isBlack ? 1 : 0) << (7 - bit);
}
raster[outRow + xByte] = b;
}
}
// ESC/POS commands
const ESC = 0x1b, GS = 0x1d;
const init = Buffer.from([ESC, 0x40]); // ESC @ (initialize)
// GS v 0 m xL xH yL yH + data
const m = 0x00; // normal density
const xL = widthBytes & 0xff;
const xH = (widthBytes >> 8) & 0xff;
const yL = height & 0xff;
const yH = (height >> 8) & 0xff;
const header = Buffer.from([GS, 0x76, 0x30, m, xL, xH, yL, yH]);
const feed = Buffer.from([0x0a, 0x0a]); // LF x2
const cut = Buffer.from([GS, 0x56, 0x42, 0]); // GS V 66 0 (partial cut)
return Buffer.concat([init, header, raster, feed, cut]);
}
/* ------------------------ Receipt HTML template (optional) ------------------------ */
function buildHTML(payload) {
const { market = {}, header = {}, items = [], totals = {}, buyer = {}, message = '' } = payload;
const widthPx = payload.paper_width_px || 576; // 80mm ≈ 576px; 58mm ≈ 384px
return `<!doctype html>
<html lang="fa" dir="rtl">
<meta charset="utf-8"/>
<title>receipt</title>
<style>
@page { size: ${Math.round(widthPx / 8)}in auto; margin: 0; }
*{ box-sizing: border-box; }
html,body{ background:#fff; color:#000; }
body{
margin:0; width:${widthPx}px;
font-family: Tahoma, Arial, sans-serif;
-webkit-print-color-adjust: exact; print-color-adjust: exact;
}
.wrap{ padding:10px 14px; }
.header{ text-align:center; }
.brand{ font-weight:700; font-size:18px; margin-bottom:4px; }
.small{ font-size:12px; line-height:1.6; }
hr{ border:0; border-top:1px dashed #333; margin:8px 0; }
table{ width:100%; border-collapse:collapse; }
th,td{ font-size:12px; padding:3px 0; }
th{ text-align:right; border-bottom:1px dashed #333; }
.row{ display:flex; justify-content:space-between; gap:10px; }
.mono{ font-family:"Courier New", Courier, monospace; }
.total{ font-weight:700; }
.thanks{ text-align:center; margin-top:8px; }
</style>
<body>
<div class="wrap">
<div class="header">
<div class="brand">${market.name ?? ''}</div>
<div class="small">
${market.slug ? `<div>${market.slug}</div>` : ''}
${market.phone ? `<div>تلفن: ${market.phone}</div>` : ''}
${market.address ? `<div>آدرس: ${market.address}</div>` : ''}
${market.operator ? `<div>صندوق: ${market.operator}</div>` : ''}
</div>
</div>
<hr>
<div class="row small">
<div>تاریخ: ${header.date ?? ''} ${header.time ?? ''}</div>
<div>شماره: ${header.id ?? ''}</div>
</div>
<table>
<thead><tr><th>کالا</th></tr></thead>
<tbody>
${items
.map((it) => {
const c = +it.count || 0,
p = +it.price || 0,
sum = Math.round(c * p);
return `<tr><td>
<div>${it.title ?? '—'}</div>
<div class="small mono">
${it.barcode ? `*${it.barcode}*` : ''}
${c} ${it.unit_title ?? ''} × ${p.toLocaleString('fa-IR')}
= <b>${sum.toLocaleString('fa-IR')}</b>
</div>
</td></tr>`;
})
.join('')}
</tbody>
</table>
<hr>
<div class="small">
${totals.shipping ? `<div class="row"><span>هزینه ارسال</span><span class="mono">${(+totals.shipping).toLocaleString('fa-IR')}</span></div>` : ''}
${totals.vat ? `<div class="row"><span>ارزش افزوده</span><span class="mono">${(+totals.vat).toLocaleString('fa-IR')} ٪</span></div>` : ''}
<div class="row total">
<span>مبلغ قابل پرداخت</span>
<span class="mono">${(+totals.total || items.reduce((s, i) => s + (+i.price || 0) * (+i.count || 0), 0)).toLocaleString('fa-IR')} تومان</span>
</div>
</div>
${payload.payments?.length ? `
<hr>
<div class="small">
<div><b>روش‌های پرداخت:</b></div>
${payload.payments
.map((p) => {
const map = { cash: 'نقد', pos: 'پوز', debt: 'بدهی', cheque: 'چک' };
return `<div class="row"><span>${map[p.type] ?? 'نقد'}</span><span class="mono">${(+p.amount || 0).toLocaleString('fa-IR')}</span></div>`;
})
.join('')}
</div>` : ''}
<hr>
<div class="small">
<div><b>اطلاعات مشتری</b></div>
<div>نام: ${buyer.name ?? '—'}</div>
<div>تلفن: ${buyer.phone ?? '—'}</div>
${buyer.address ? `<div>آدرس: ${buyer.address}</div>` : ''}
</div>
${message ? `<div class="thanks small">${message}</div>` : ''}
<div class="thanks small">از خرید شما سپاسگزاریم 🌟</div>
</div>
</body>
</html>`;
}
/* ------------------------ Routes ------------------------ */
app.get('/health', (req, res) => res.json({ ok: true }));
// POST /print (JSON data -> buildHTML -> render -> raster -> print)
app.post('/print', async (req, res) => {
try {
const { printer_ip, printer_port = 9100, data } = req.body;
if (!printer_ip) return res.status(400).json({ ok: false, error: 'printer_ip is required' });
if (!data || !Array.isArray(data.items))
return res.status(400).json({ ok: false, error: 'data.items (array) is required' });
const widthPx = data.paper_width_px || 576;
const html = buildHTML(data);
const pngBuffer = await renderHtmlToPngBuffer(html, widthPx);
const threshold = Number(data.threshold) || 235;
const { width, height, rowBytes, packed } = await imageToMonochrome({
inputBuffer: pngBuffer,
widthLimitPx: widthPx,
threshold,
dither: true,
});
const escposBytes = buildEscposRasterGSv0({ packed, width, height, rowBytes, cut: true });
await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escposBytes });
res.json({ ok: true });
} catch (e) {
console.error('PRINT ERROR:', e);
res.status(500).json({ ok: false, error: String(e?.message || e) });
}
});
// POST /print-html (HTML string -> render -> raster -> [preview|print])
// Body: { printer_ip, printer_port?, html, paper_width_px?, threshold?, invert?, preview? }
app.post('/print-html', async (req, res) => {
try {
const {
html, printer_ip, printer_port = 9100,
paper_width_px = 576, threshold = 235,
invert = false, preview, mode // 'escstar' or 'raster'
} = req.body;
if (!html) return res.status(400).json({ ok: false, error: 'html is required' });
if (!printer_ip && !preview) return res.status(400).json({ ok: false, error: 'printer_ip is required for printing' });
// 1) Render HTML to PNG
const pngBuffer = await renderHtmlToPngBuffer(html, paper_width_px);
// 2) Binarize
const { width, height, rowBytes, packed, mono, approxNonWhite, bmpBuffer } =
await imageToMonochrome({ inputBuffer: pngBuffer, widthLimitPx: paper_width_px, threshold, dither: true });
if (preview) {
const dataUrl = `data:image/bmp;base64,${bmpBuffer.toString('base64')}`;
return res.json({ ok: true, dataUrl, approx_nonwhite_pixels: approxNonWhite, width, height });
}
// 3) Build ESC/POS by mode
let escpos;
if (String(mode).toLowerCase() === 'escstar') {
escpos = buildEscposBitImage_ESCStar({ mono, width, height, cut: true });
} else {
escpos = buildEscposRasterGSv0({ packed, width, height, rowBytes, cut: true }); // GS v 0
}
// 4) Send
await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escpos });
res.json({ ok: true, width, height, approx_nonwhite_pixels: approxNonWhite, mode: mode || 'raster' });
} catch (e) {
console.error('print-html error:', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// POST /test (built-in demo)
app.post('/test', async (req, res) => {
try {
const { printer_ip, printer_port = 9100 } = req.body;
if (!printer_ip) return res.status(400).json({ ok: false, error: 'printer_ip is required' });
const demo = {
paper_width_px: 576,
market: { name: 'نمونه فروشگاه', phone: '021-12345678', address: 'تهران - خیابان مثال', operator: 'صندوق 1' },
header: { id: 'T-' + Date.now(), date: '1404/07/26', time: '12:34:56' },
items: [
{ title: 'نان باگت', barcode: '123', price: 12000, count: 2, unit_title: 'عدد' },
{ title: 'شیر کم‌چرب 1 لیتری', barcode: '456', price: 34000, count: 1, unit_title: 'عدد' },
],
totals: { total: 58000 },
payments: [{ type: 'pos', amount: 58000 }],
buyer: { name: 'مشتری نمونه', phone: '09120000000' },
message: 'خرید خوبی داشته باشید ✨',
};
const html = buildHTML(demo);
const pngBuffer = await renderHtmlToPngBuffer(html, demo.paper_width_px);
// Either path works; we use 1-bit dithering here:
const { width, height, rowBytes, packed } = await imageToMonochrome({
inputBuffer: pngBuffer,
widthLimitPx: demo.paper_width_px,
threshold: 235,
dither: true,
});
const escposBytes = buildEscposRasterGSv0({ packed, width, height, rowBytes, cut: true });
await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escposBytes });
// Save a debug PNG
const debugPath = path.join(OUT_DIR, `test_${Date.now()}.png`);
fs.writeFileSync(debugPath, pngBuffer);
res.json({ ok: true, debug_png: path.basename(debugPath) });
} catch (e) {
console.error('TEST PRINT ERROR:', e);
res.status(500).json({ ok: false, error: String(e?.message || e) });
}
});
// --- NEW /print-image using our own rasterizer (no escpos.Image) ---
// --- the complete route you asked for ---
app.post('/print-image', async (req, res) => {
try {
let {
pngBase64,
width = 576, // ← default to 576 for 80mm
threshold = 210,
invert = false,
printer_ip,
printer_port = 9100
} = req.body || {};
if (!pngBase64) return res.status(400).json({ ok: false, error: 'pngBase64 missing' });
if (!printer_ip) return res.status(400).json({ ok: false, error: 'printer_ip missing' });
const src = Buffer.from(pngBase64, 'base64');
let pipeline = sharp(src).flatten({ background: '#FFFFFF' });
const meta = await pipeline.metadata();
// 🔒 hard clamp to device width (TT200/GK888T ≈ 576 @ 203dpi)
const MAX_WIDTH = 576;
const targetW = Math.min(Number(width) > 0 ? Number(width) : (meta.width || MAX_WIDTH), MAX_WIDTH);
if (targetW !== meta.width) {
pipeline = pipeline.resize({ width: targetW, withoutEnlargement: false });
}
const preparedPng = await pipeline.png().toBuffer();
const escposBytes = await pngBufferToEscposRaster(preparedPng, Number(threshold), Boolean(invert));
await sendRawToTcp({ host: printer_ip, port: Number(printer_port), data: escposBytes });
const finalMeta = await sharp(preparedPng).metadata();
console.log('Printed PNG size:', finalMeta.width, 'x', finalMeta.height);
return res.json({ ok: true, width: finalMeta.width, height: finalMeta.height });
} catch (e) {
console.error('print-image error:', e);
return res.status(500).json({ ok: false, error: e.message || String(e) });
}
});/* ------------------------ Start servers ------------------------ */
// HTTP (kept for local tests)
const HTTP_PORT = Number(process.env.PORT) || 9091;
app.listen(HTTP_PORT, '0.0.0.0', () => {
console.log(`HTTP listening on :${HTTP_PORT}`);
for (const ip of lanIPs()) console.log(` http://${ip}:${HTTP_PORT}`);
});
// HTTPS (to avoid mixed-content from https://hamekara.com)
try {
const HTTPS_PORT = Number(process.env.SSL_PORT) || 9092;
// If you used mkcert:
const KEY_FILE = path.join(__dirname, '192.168.100.15-key.pem');
const CERT_FILE = path.join(__dirname, '192.168.100.15.pem');
const key = fs.readFileSync(KEY_FILE);
const cert = fs.readFileSync(CERT_FILE);
https.createServer({ key, cert }, app).listen(HTTPS_PORT, '0.0.0.0', () => {
console.log(`HTTPS listening on :${HTTPS_PORT}`);
for (const ip of lanIPs()) console.log(` https://${ip}:${HTTPS_PORT}`);
console.log('CORS allowed origins:', [...ALLOWED_ORIGINS].join(', '));
console.log('Endpoints: GET /health | POST /print | POST /print-html | POST /test');
});
} catch (e) {
console.warn('HTTPS not started (missing/invalid cert). Create mkcert/OpenSSL certs and restart.');
}