// 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 `
| کالا |
|---|
|
${it.title ?? '—'}
${it.barcode ? `*${it.barcode}*` : ''}
${c} ${it.unit_title ?? ''} × ${p.toLocaleString('fa-IR')}
= ${sum.toLocaleString('fa-IR')}
|