Initial commit: Epson ePOS Node backend + Vue3 UI (printer matricial2)

This commit is contained in:
2025-09-27 16:06:57 -06:00
commit f3c13b356b
26 changed files with 5015 additions and 0 deletions

191
src/client/App.vue Normal file
View File

@@ -0,0 +1,191 @@
<template>
<div class="wrap">
<header class="glass card">
<h1>Printer Central</h1>
<p class="subtle">Vista previa y control ePOS (monocromático)</p>
</header>
<main class="grid">
<section class="glass card">
<h2>Texto</h2>
<textarea v-model="form.text" rows="6" placeholder="Escribe el texto a imprimir..." />
<div class="controls">
<label>
Align
<select v-model="form.align">
<option value="">(sin cambio)</option>
<option value="left">left</option>
<option value="center">center</option>
<option value="right">right</option>
</select>
</label>
<label>
Font
<select v-model="form.font">
<option value="">(sin cambio)</option>
<option value="font_a">A</option>
<option value="font_b">B</option>
<option value="font_c">C</option>
<option value="font_d">D</option>
<option value="font_e">E</option>
<option value="special_a">Special A</option>
<option value="special_b">Special B</option>
</select>
</label>
<label>Width <input type="number" min="1" max="8" v-model.number="form.w" /></label>
<label>Height <input type="number" min="1" max="8" v-model.number="form.h" /></label>
<label><input type="checkbox" v-model="form.bold" /> Bold</label>
<label><input type="checkbox" v-model="form.underline" /> Underline</label>
<label><input type="checkbox" v-model="form.reverse" /> Reverse</label>
<label>Smooth <input type="checkbox" v-model="form.smooth" /></label>
<label>
Color
<select v-model="form.color">
<option value="">(default)</option>
<option value="color_1">1</option>
<option value="color_2">2</option>
<option value="color_3">3</option>
<option value="color_4">4</option>
</select>
</label>
<label>Feed lines <input type="number" min="0" max="255" v-model.number="form.feedLines" /></label>
<label>
Cut
<select v-model="form.cut">
<option value="">(no)</option>
<option value="no_feed">no_feed</option>
<option value="feed">feed</option>
<option value="reserve">reserve</option>
<option value="no_feed_fullcut">no_feed_fullcut</option>
<option value="feed_fullcut">feed_fullcut</option>
<option value="reserve_fullcut">reserve_fullcut</option>
</select>
</label>
</div>
<div class="actions">
<button class="btn" @click="printText">Imprimir texto</button>
<button class="btn ghost" @click="resetForm">Limpiar</button>
</div>
</section>
<section class="glass card">
<h2>Avanzado</h2>
<div class="chips">
<button class="chip" @click="pushOp({ op: 'feedLine', line: 2 })">Feed 2</button>
<button class="chip" @click="pushOp({ op: 'cut', type: 'feed' })">Cortar</button>
<button class="chip" @click="pushOp({ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' })">Pulse</button>
<button class="chip" @click="pushOp({ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 })">QR</button>
<button class="chip" @click="pushOp({ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 })">Barcode</button>
</div>
<pre class="json">{{ ops }}</pre>
<div class="actions">
<button class="btn" @click="sendOps">Enviar lote</button>
<button class="btn ghost" @click="clearOps">Limpiar lote</button>
</div>
</section>
<section class="glass card">
<h2>Resultado</h2>
<pre class="json result">{{ JSON.stringify(result, null, 2) }}</pre>
</section>
</main>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
const form = reactive({
text: '',
align: '',
font: '',
w: undefined,
h: undefined,
bold: false,
underline: false,
reverse: false,
smooth: false,
color: '',
feedLines: undefined,
cut: ''
});
const ops = ref([]);
const result = ref({ ok: true, msg: 'Listo.' });
function pushOp(op) { ops.value = [...ops.value, op]; }
function clearOps() { ops.value = []; }
function resetForm() {
Object.assign(form, { text: '', align: '', font: '', w: undefined, h: undefined, bold: false, underline: false, reverse: false, smooth: false, color: '', feedLines: undefined, cut: '' });
}
async function callApi(path, body) {
const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
const data = await res.json();
result.value = data;
}
async function printText() {
const options = {};
if (form.align) options.align = form.align;
if (form.font) options.font = form.font;
if (form.w || form.h) options.size = { width: form.w, height: form.h };
options.style = { em: form.bold, ul: form.underline, reverse: form.reverse };
if (form.color) options.style.color = form.color;
if (form.feedLines != null && form.feedLines !== '') options.feedLines = Number(form.feedLines);
if (form.cut) options.cut = form.cut;
await callApi('/api/print/text', { text: form.text, options });
}
async function sendOps() { await callApi('/api/print', { operations: ops.value }); }
</script>
<style>
:root {
--bg: #0f1113;
--fg: #ececec;
--muted: #a9a9a9;
--glass: rgba(255,255,255,0.08);
--border: rgba(255,255,255,0.12);
--accent: #cfcfcf;
}
* { box-sizing: border-box; }
html, body, #app { height: 100%; }
body { margin: 0; background: linear-gradient(180deg, #0f1113 0%, #1a1d21 100%); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px; }
header h1 { margin: 0 0 4px; font-size: 22px; letter-spacing: 0.2px; }
.subtle { color: var(--muted); margin: 0; }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); margin-top: 16px; }
section.card:nth-child(1) { grid-column: span 12; }
section.card:nth-child(2) { grid-column: span 12; }
section.card:nth-child(3) { grid-column: span 12; }
@media (min-width: 860px) {
section.card:nth-child(1) { grid-column: span 7; }
section.card:nth-child(2) { grid-column: span 5; }
section.card:nth-child(3) { grid-column: span 12; }
}
.glass { background: var(--glass); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid var(--border); border-radius: 14px; }
.card { padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05); }
h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; color: var(--accent); }
textarea { width: 100%; color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 10px; padding: 10px; resize: vertical; }
select, input[type="number"] { color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 10px; padding: 6px 8px; }
label { display: inline-flex; gap: 8px; align-items: center; margin: 6px 10px 6px 0; color: var(--muted); }
.controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.actions { margin-top: 12px; display: flex; gap: 8px; }
.btn { appearance: none; border: 1px solid var(--border); background: rgba(255,255,255,0.08); color: var(--fg); border-radius: 10px; padding: 8px 12px; cursor: pointer; transition: 150ms ease; }
.btn:hover { background: rgba(255,255,255,0.12); }
.btn.ghost { background: transparent; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
.chip { border: 1px dashed var(--border); background: rgba(255,255,255,0.04); color: var(--fg); border-radius: 999px; padding: 6px 10px; cursor: pointer; }
.json { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 10px; white-space: pre-wrap; word-break: break-word; }
.result { color: #e6e6e6; }
</style>

5
src/client/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');

124
src/eposBuilder.js Normal file
View File

@@ -0,0 +1,124 @@
// Server-side minimal ePOS-Print XML builder for Epson printers
// Focused on printer-only tags. Builds the inner message for <epos-print>.
function escapeXml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&apos;");
}
class EposMessageBuilder {
constructor() {
this.parts = [];
}
// Text and styles
text(content) { this.parts.push(`<text>${escapeXml(content)}</text>`); return this; }
textLang(lang) { this.parts.push(`<text lang="${lang}"/>`); return this; }
textAlign(align) { this.parts.push(`<text align="${align}"/>`); return this; }
textRotate(rotate) { this.parts.push(`<text rotate="${rotate ? 'true' : 'false'}"/>`); return this; }
textLineSpace(linespc) { this.parts.push(`<text linespc="${linespc}"/>`); return this; }
textFont(font) { this.parts.push(`<text font="${font}"/>`); return this; }
textSmooth(smooth) { this.parts.push(`<text smooth="${smooth ? 'true' : 'false'}"/>`); return this; }
textDouble(dw, dh) {
const attrs = [dw !== undefined ? `dw="${dw ? 'true' : 'false'}"` : '', dh !== undefined ? `dh="${dh ? 'true' : 'false'}"` : ''].filter(Boolean).join(' ');
this.parts.push(`<text ${attrs}/>`.trim());
return this;
}
textSize(width, height) {
const attrs = [width !== undefined ? `width="${width}"` : '', height !== undefined ? `height="${height}"` : ''].filter(Boolean).join(' ');
this.parts.push(`<text ${attrs}/>`.trim());
return this;
}
textStyle({ reverse, ul, em, color } = {}) {
const attrs = [];
if (reverse !== undefined) attrs.push(`reverse="${reverse ? 'true' : 'false'}"`);
if (ul !== undefined) attrs.push(`ul="${ul ? 'true' : 'false'}"`);
if (em !== undefined) attrs.push(`em="${em ? 'true' : 'false'}"`);
if (color !== undefined) attrs.push(`color="${color}"`);
this.parts.push(`<text ${attrs.join(' ')}/>`.trim());
return this;
}
textPosition(x) { this.parts.push(`<text x="${x}"/>`); return this; }
textVPosition(y) { this.parts.push(`<text y="${y}"/>`); return this; }
// Feed
feed() { this.parts.push('<feed/>'); return this; }
feedUnit(unit) { this.parts.push(`<feed unit="${unit}"/>`); return this; }
feedLine(line) { this.parts.push(`<feed line="${line}"/>`); return this; }
feedPosition(pos) { this.parts.push(`<feed pos="${pos}"/>`); return this; }
// Cut
cut(type) { this.parts.push(`<cut type="${type}"/>`); return this; }
// Drawer
pulse({ drawer = 'drawer_1', time = 'pulse_200' } = {}) { this.parts.push(`<pulse drawer="${drawer}" time="${time}"/>`); return this; }
// Barcode and symbols (minimal)
barcode(data, { type, hri, font, width, height } = {}) {
const attrs = [];
if (type) attrs.push(`type="${type}"`);
if (hri) attrs.push(`hri="${hri}"`);
if (font) attrs.push(`font="${font}"`);
if (width) attrs.push(`width="${width}"`);
if (height) attrs.push(`height="${height}"`);
this.parts.push(`<barcode ${attrs.join(' ')}>${escapeXml(data)}</barcode>`.trim());
return this;
}
qrcode(data, { model = 'qrcode_model_2', level = 'level_m', size } = {}) {
const attrs = [`type="${model}"`, `level="${level}"`];
if (size) attrs.push(`size="${size}"`);
this.parts.push(`<symbol ${attrs.join(' ')}>${escapeXml(data)}</symbol>`);
return this;
}
// Logo (NV) and image are omitted for now; can be added later.
build() {
return this.parts.join('');
}
}
// Accepts a list of operations: [{ op: 'text', args: {...} | [] }, ...]
function buildFromOperations(ops) {
const b = new EposMessageBuilder();
for (const item of ops || []) {
const { op } = item;
if (!op) continue;
switch (op) {
case 'text': b.text(item.value ?? ''); break;
case 'textLang': b.textLang(item.lang); break;
case 'textAlign': b.textAlign(item.align); break;
case 'textRotate': b.textRotate(!!item.rotate); break;
case 'textLineSpace': b.textLineSpace(item.linespc); break;
case 'textFont': b.textFont(item.font); break;
case 'textSmooth': b.textSmooth(!!item.smooth); break;
case 'textDouble': b.textDouble(item.dw, item.dh); break;
case 'textSize': b.textSize(item.width, item.height); break;
case 'textStyle': b.textStyle({ reverse: item.reverse, ul: item.ul, em: item.em, color: item.color }); break;
case 'textPosition': b.textPosition(item.x); break;
case 'textVPosition': b.textVPosition(item.y); break;
case 'feed': b.feed(); break;
case 'feedUnit': b.feedUnit(item.unit); break;
case 'feedLine': b.feedLine(item.line); break;
case 'feedPosition': b.feedPosition(item.pos); break;
case 'cut': b.cut(item.type || 'feed'); break;
case 'pulse': b.pulse({ drawer: item.drawer, time: item.time }); break;
case 'barcode': b.barcode(item.data || '', { type: item.type, hri: item.hri, font: item.font, width: item.width, height: item.height }); break;
case 'qrcode': b.qrcode(item.data || '', { model: item.model, level: item.level, size: item.size }); break;
default:
// ignore unknown for now
break;
}
}
return b.build();
}
module.exports = {
EposMessageBuilder,
buildFromOperations,
};

158
src/server.js Normal file
View File

@@ -0,0 +1,158 @@
const express = require('express');
const axios = require('axios');
const https = require('https');
const { buildFromOperations, EposMessageBuilder } = require('./eposBuilder');
const Jimp = require('jimp');
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.static('public'));
// Config
const PRINTER_HOST = process.env.PRINTER_HOST || '192.168.87.147';
const PRINTER_DEVICE_ID = process.env.PRINTER_DEVICE_ID || 'matricial2';
const PRINTER_TIMEOUT_MS = parseInt(process.env.PRINTER_TIMEOUT_MS || '60000', 10);
function buildSoapEnvelope(inner) {
return (
'<?xml version="1.0" encoding="utf-8"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">' +
'<s:Body>' +
'<epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">' +
inner +
'</epos-print>' +
'</s:Body>' +
'</s:Envelope>'
);
}
async function sendToPrinter(xml) {
const url = `https://${PRINTER_HOST}/cgi-bin/epos/service.cgi?devid=${encodeURIComponent(PRINTER_DEVICE_ID)}&timeout=${PRINTER_TIMEOUT_MS}`;
const httpsAgent = new https.Agent({ rejectUnauthorized: false }); // self-signed OK
const res = await axios.post(url, xml, {
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
httpsAgent,
timeout: PRINTER_TIMEOUT_MS + 5000,
validateStatus: () => true,
});
return { status: res.status, headers: res.headers, data: typeof res.data === 'string' ? res.data : String(res.data) };
}
async function handlePrintOps(res, operations = [], dryRun = false) {
const inner = buildFromOperations(operations);
const soap = buildSoapEnvelope(inner);
if (dryRun) {
return res.json({ ok: true, dryRun: true, soap });
}
const result = await sendToPrinter(soap);
const success = /success\s*=\s*"true"/.test(result.data);
const codeMatch = result.data.match(/code=\"([^\"]*)\"/);
const code = codeMatch ? codeMatch[1] : '';
return res.json({ ok: success, httpStatus: result.status, code, raw: result.data });
}
// Generic endpoint: accepts a list of operations and optional dryRun
app.post('/api/print', async (req, res) => {
try {
const { operations = [], dryRun = false } = req.body || {};
await handlePrintOps(res, operations, dryRun);
} catch (err) {
return res.status(500).json({ ok: false, error: err.message });
}
});
// Convenience endpoints
app.post('/api/print/text', async (req, res) => {
try {
const { text = '', options = {} } = req.body || {};
const ops = [];
if (options.align) ops.push({ op: 'textAlign', align: options.align });
if (options.font) ops.push({ op: 'textFont', font: options.font });
if (options.size) ops.push({ op: 'textSize', width: options.size.width, height: options.size.height });
if (options.style) ops.push({ op: 'textStyle', ...options.style });
ops.push({ op: 'text', value: text });
if (options.feedLines) ops.push({ op: 'feedLine', line: options.feedLines });
if (options.cut) ops.push({ op: 'cut', type: options.cut });
await handlePrintOps(res, ops, false);
} catch (err) {
return res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/print/cut', async (req, res) => {
try {
const type = (req.body && req.body.type) || 'feed';
await handlePrintOps(res, [{ op: 'cut', type }], false);
} catch (err) {
return res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/print/pulse', async (req, res) => {
try {
const { drawer, time } = req.body || {};
await handlePrintOps(res, [{ op: 'pulse', drawer, time }], false);
} catch (err) {
return res.status(500).json({ ok: false, error: err.message });
}
});
// Image print endpoint: reads an image file and sends as mono raster
app.post('/api/print/image', async (req, res) => {
try {
const { path, width, threshold = 128, mode = 'mono' } = req.body || {};
if (!path) return res.status(400).json({ ok: false, error: 'path required' });
const img = await Jimp.read(path);
let targetWidth = width || img.bitmap.width;
if (targetWidth <= 0) targetWidth = img.bitmap.width;
const scale = targetWidth / img.bitmap.width;
const targetHeight = Math.max(1, Math.round(img.bitmap.height * scale));
img.resize(targetWidth, targetHeight, Jimp.RESIZE_BILINEAR);
img.grayscale();
// pack bits MSB first per byte
const bytesPerRow = Math.ceil(targetWidth / 8);
const out = Buffer.alloc(bytesPerRow * targetHeight);
let outIdx = 0;
for (let y = 0; y < targetHeight; y++) {
let byte = 0; let bit = 7; let rowBytes = 0;
for (let x = 0; x < targetWidth; x++) {
const idx = (y * targetWidth + x) * 4;
const rgba = img.bitmap.data;
const r = rgba[idx], g = rgba[idx + 1], b = rgba[idx + 2];
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
const isBlack = lum < threshold; // simple threshold
if (isBlack) byte |= (1 << bit);
bit--;
if (bit < 0) {
out[outIdx++] = byte;
rowBytes++;
byte = 0; bit = 7;
}
}
// pad remaining bits
if (bit !== 7) { out[outIdx++] = byte; rowBytes++; }
// pad to full row bytes if needed
while (rowBytes < bytesPerRow) { out[outIdx++] = 0; rowBytes++; }
}
const base64 = out.toString('base64');
const builder = new EposMessageBuilder();
builder.imageRaw({ width: targetWidth, height: targetHeight, mode, base64 });
const soap = buildSoapEnvelope(builder.build());
const result = await sendToPrinter(soap);
const success = /success\s*=\s*"true"/.test(result.data);
const codeMatch = result.data.match(/code=\"([^\"]*)\"/);
const code = codeMatch ? codeMatch[1] : '';
return res.json({ ok: success, httpStatus: result.status, code, raw: result.data, width: targetWidth, height: targetHeight });
} catch (err) {
return res.status(500).json({ ok: false, error: err.message });
}
});
const PORT = process.env.PORT || 3030;
app.listen(PORT, () => {
console.log(`Printer Central UI listening on http://localhost:${PORT}`);
console.log(`Target printer: https://${PRINTER_HOST} devid=${PRINTER_DEVICE_ID}`);
});