// 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 ` receipt
${market.name ?? ''}
${market.slug ? `
${market.slug}
` : ''} ${market.phone ? `
تلفن: ${market.phone}
` : ''} ${market.address ? `
آدرس: ${market.address}
` : ''} ${market.operator ? `
صندوق: ${market.operator}
` : ''}

تاریخ: ${header.date ?? ''} ${header.time ?? ''}
شماره: ${header.id ?? ''}
${items .map((it) => { const c = +it.count || 0, p = +it.price || 0, sum = Math.round(c * p); return ``; }) .join('')}
کالا
${it.title ?? '—'}
${it.barcode ? `*${it.barcode}*` : ''} ${c} ${it.unit_title ?? ''} × ${p.toLocaleString('fa-IR')} = ${sum.toLocaleString('fa-IR')}

${totals.shipping ? `
هزینه ارسال${(+totals.shipping).toLocaleString('fa-IR')}
` : ''} ${totals.vat ? `
ارزش افزوده${(+totals.vat).toLocaleString('fa-IR')} ٪
` : ''}
مبلغ قابل پرداخت ${(+totals.total || items.reduce((s, i) => s + (+i.price || 0) * (+i.count || 0), 0)).toLocaleString('fa-IR')} تومان
${payload.payments?.length ? `
روش‌های پرداخت:
${payload.payments .map((p) => { const map = { cash: 'نقد', pos: 'پوز', debt: 'بدهی', cheque: 'چک' }; return `
${map[p.type] ?? 'نقد'}${(+p.amount || 0).toLocaleString('fa-IR')}
`; }) .join('')}
` : ''}
اطلاعات مشتری
نام: ${buyer.name ?? '—'}
تلفن: ${buyer.phone ?? '—'}
${buyer.address ? `
آدرس: ${buyer.address}
` : ''}
${message ? `
${message}
` : ''}
از خرید شما سپاسگزاریم 🌟
`; } /* ------------------------ 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.'); }