641 lines
24 KiB
JavaScript
641 lines
24 KiB
JavaScript
// 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.');
|
||
}
|