Initial commit: Epson ePOS Node backend + Vue3 UI (printer matricial2)
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Env/config
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Vite build output (we build to public/)
|
||||||
|
public/assets/
|
||||||
|
public/*.map
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
Printer Central (Epson ePOS)
|
||||||
|
|
||||||
|
Resumen
|
||||||
|
- Backend Node.js que envía comandos ePOS-Print vía SOAP a una impresora Epson configurada en HTTPS con certificado autofirmado.
|
||||||
|
- Frontend Vue 3 (Vite) con glassmorphism en escala de grises para escribir texto y activar funciones: alineación, fuente, tamaño, estilos, feed, corte, cajón, QR y código de barras. También permite componer un lote de operaciones genéricas.
|
||||||
|
|
||||||
|
Requisitos
|
||||||
|
- Node.js 18+ (probado con v22) y npm.
|
||||||
|
|
||||||
|
Instalación y ejecución
|
||||||
|
1) Instalar dependencias:
|
||||||
|
npm install
|
||||||
|
|
||||||
|
2) Configurar destino (opcional, valores por defecto entre paréntesis):
|
||||||
|
export PRINTER_HOST=192.168.87.147 # IP/DNS de la impresora
|
||||||
|
export PRINTER_DEVICE_ID=matricial2 # DeviceId
|
||||||
|
export PRINTER_TIMEOUT_MS=60000 # Timeout ePOS
|
||||||
|
|
||||||
|
3) Desarrollo con hot reload (cliente + servidor):
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
- Frontend (Vite + Vue 3) en http://localhost:5173
|
||||||
|
- Proxy /api → http://localhost:3030 (Express)
|
||||||
|
- El servidor se recarga con nodemon al cambiar archivos en src/
|
||||||
|
|
||||||
|
4) Producción (build del cliente + servidor Express):
|
||||||
|
npm run build # genera assets en public/
|
||||||
|
npm start # sirve API y estáticos desde public/ (por defecto en :3030)
|
||||||
|
|
||||||
|
API principal
|
||||||
|
- POST /api/print
|
||||||
|
Body JSON: { operations: [ { op: string, ...attrs }, ... ] }
|
||||||
|
Construye el cuerpo de <epos-print> según las operaciones y lo envía dentro de un sobre SOAP a:
|
||||||
|
https://PRINTER_HOST/cgi-bin/epos/service.cgi?devid=PRINTER_DEVICE_ID&timeout=PRINTER_TIMEOUT_MS
|
||||||
|
|
||||||
|
Operaciones soportadas (clave op)
|
||||||
|
- text { value }
|
||||||
|
- textLang { lang }
|
||||||
|
- textAlign { align: left|center|right }
|
||||||
|
- textRotate { rotate: boolean }
|
||||||
|
- textLineSpace { linespc }
|
||||||
|
- textFont { font: font_a|font_b|... }
|
||||||
|
- textSmooth { smooth: boolean }
|
||||||
|
- textDouble { dw: boolean, dh: boolean }
|
||||||
|
- textSize { width: 1..8, height: 1..8 }
|
||||||
|
- textStyle { reverse?: boolean, ul?: boolean, em?: boolean, color?: color_1..color_4 }
|
||||||
|
- textPosition { x }
|
||||||
|
- textVPosition { y }
|
||||||
|
- feed {}
|
||||||
|
- feedUnit { unit }
|
||||||
|
- feedLine { line }
|
||||||
|
- feedPosition { pos }
|
||||||
|
- cut { type: no_feed|feed|reserve|no_feed_fullcut|feed_fullcut|reserve_fullcut }
|
||||||
|
- pulse { drawer?: drawer_1|drawer_2, time?: pulse_100|...|pulse_500 }
|
||||||
|
- barcode { data, type, hri, font, width, height }
|
||||||
|
- qrcode { data, model, level, size }
|
||||||
|
|
||||||
|
Notas
|
||||||
|
- El backend desactiva la verificación TLS (self-signed) únicamente para la conexión con la impresora.
|
||||||
|
- Si necesitas más funciones (logo/imágenes, páginas, líneas, etc.), se pueden añadir fácilmente replicando los métodos del ePOSBuilder oficial.
|
||||||
6
epos-2.27.0.js
Normal file
6
epos-2.27.0.js
Normal file
File diff suppressed because one or more lines are too long
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Printer Central</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/client/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
3812
package-lock.json
generated
Normal file
3812
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "printer-central",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "concurrently -n server,client -c auto \"npm:dev:server\" \"npm:dev:client\"",
|
||||||
|
"dev:server": "PORT=3030 nodemon --quiet --watch src --ext js src/server.js",
|
||||||
|
"dev:client": "vite",
|
||||||
|
"build": "vite build --outDir public",
|
||||||
|
"test:1": "node scripts/tests/01_text_simple.js",
|
||||||
|
"test:2": "node scripts/tests/02_align_cut.js",
|
||||||
|
"test:3": "node scripts/tests/03_font_size.js",
|
||||||
|
"test:4": "node scripts/tests/04_batch_feeds.js",
|
||||||
|
"test:5": "node scripts/tests/05_barcode.js",
|
||||||
|
"test:6": "node scripts/tests/06_qr.js",
|
||||||
|
"test:7": "node scripts/tests/07_pulse.js",
|
||||||
|
"test:all": "node scripts/test_all.js",
|
||||||
|
"print:ref": "node scripts/print_referencia.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"jimp": "^0.22.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"vite": "^5.4.20",
|
||||||
|
"vue": "^3.5.22"
|
||||||
|
}
|
||||||
|
}
|
||||||
158
public/index.html
Normal file
158
public/index.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Printer Central</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 24px; }
|
||||||
|
fieldset { margin-bottom: 16px; }
|
||||||
|
label { display: inline-block; margin: 6px 8px 6px 0; }
|
||||||
|
input[type="text"], textarea, select { padding: 6px; }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.btn { padding: 8px 12px; cursor: pointer; }
|
||||||
|
#result { white-space: pre-wrap; background: #f6f8fa; padding: 8px; border: 1px solid #ddd; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Panel de Impresión (Node → Epson ePOS)</h1>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Texto</legend>
|
||||||
|
<div class="row">
|
||||||
|
<textarea id="text" rows="4" cols="60" placeholder="Escribe el texto a imprimir..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Align
|
||||||
|
<select id="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 id="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 id="w" type="number" min="1" max="8" step="1" placeholder="1" style="width:5rem" />
|
||||||
|
</label>
|
||||||
|
<label>Height
|
||||||
|
<input id="h" type="number" min="1" max="8" step="1" placeholder="1" style="width:5rem" />
|
||||||
|
</label>
|
||||||
|
<label><input type="checkbox" id="bold"/> Bold</label>
|
||||||
|
<label><input type="checkbox" id="underline"/> Underline</label>
|
||||||
|
<label><input type="checkbox" id="reverse"/> Reverse</label>
|
||||||
|
<label>Smooth <input type="checkbox" id="smooth"/></label>
|
||||||
|
<label>Color
|
||||||
|
<select id="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 id="feedLines" type="number" min="0" max="255" step="1" style="width:5rem"/></label>
|
||||||
|
<label>Cut
|
||||||
|
<select id="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>
|
||||||
|
<button class="btn" id="btnPrintText">Imprimir texto</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Avanzado (componer operaciones)</legend>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" data-op="feedLine" data-arg="line:2">Feed 2 líneas</button>
|
||||||
|
<button class="btn" data-op="cut" data-arg="type:feed">Cortar (feed)</button>
|
||||||
|
<button class="btn" id="btnDrawer">Abrir cajón (pulse)</button>
|
||||||
|
<button class="btn" id="btnQRCode">QR demo</button>
|
||||||
|
<button class="btn" id="btnBarcode">Barcode demo</button>
|
||||||
|
<button class="btn" id="btnEnviar">Enviar lote</button>
|
||||||
|
<button class="btn" id="btnLimpiar">Limpiar lote</button>
|
||||||
|
</div>
|
||||||
|
<div>Operaciones en cola:</div>
|
||||||
|
<pre id="ops"></pre>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Resultado</legend>
|
||||||
|
<pre id="result">Listo.</pre>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const ops = [];
|
||||||
|
function refreshOps() { $('ops').textContent = JSON.stringify(ops, null, 2); }
|
||||||
|
refreshOps();
|
||||||
|
|
||||||
|
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').textContent = JSON.stringify(data, null, 2);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btnPrintText').onclick = async () => {
|
||||||
|
const text = $('text').value;
|
||||||
|
const options = {};
|
||||||
|
const align = $('align').value; if (align) options.align = align;
|
||||||
|
const font = $('font').value; if (font) options.font = font;
|
||||||
|
const w = parseInt($('w').value || '');
|
||||||
|
const h = parseInt($('h').value || '');
|
||||||
|
if (!isNaN(w) || !isNaN(h)) options.size = { width: isNaN(w)? undefined : w, height: isNaN(h)? undefined : h };
|
||||||
|
options.style = { em: $('bold').checked, ul: $('underline').checked, reverse: $('reverse').checked };
|
||||||
|
const color = $('color').value; if (color) options.style.color = color;
|
||||||
|
if ($('smooth').checked) options.smooth = true; // optional, not wired in shortcut endpoint
|
||||||
|
const feedLines = parseInt($('feedLines').value || ''); if (!isNaN(feedLines)) options.feedLines = feedLines;
|
||||||
|
const cut = $('cut').value; if (cut) options.cut = cut;
|
||||||
|
await callApi('/api/print/text', { text, options });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buttons to queue operations
|
||||||
|
document.querySelectorAll('[data-op]').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const op = btn.getAttribute('data-op');
|
||||||
|
const arg = btn.getAttribute('data-arg');
|
||||||
|
const item = { op };
|
||||||
|
if (arg) {
|
||||||
|
// parse simple key:value pairs
|
||||||
|
arg.split(',').forEach(kv => { const [k,v] = kv.split(':'); item[k.trim()] = isNaN(v) ? v : Number(v); });
|
||||||
|
}
|
||||||
|
ops.push(item); refreshOps();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnDrawer').onclick = () => { ops.push({ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' }); refreshOps(); };
|
||||||
|
$('btnQRCode').onclick = () => {
|
||||||
|
ops.push({ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 });
|
||||||
|
refreshOps();
|
||||||
|
};
|
||||||
|
$('btnBarcode').onclick = () => {
|
||||||
|
ops.push({ op: 'barcode', data: '123456789012', type: 'ean13', hri: 'below', width: 3, height: 80 });
|
||||||
|
refreshOps();
|
||||||
|
};
|
||||||
|
$('btnEnviar').onclick = async () => { await callApi('/api/print', { operations: ops }); };
|
||||||
|
$('btnLimpiar').onclick = () => { ops.length = 0; refreshOps(); };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
BIN
referencia.jpeg
Normal file
BIN
referencia.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
35
scripts/print_referencia.js
Normal file
35
scripts/print_referencia.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Print the referencia.jpeg as a raster image via backend Node
|
||||||
|
process.env.PRINTER_HOST = process.env.PRINTER_HOST || '192.168.87.147';
|
||||||
|
process.env.PRINTER_DEVICE_ID = process.env.PRINTER_DEVICE_ID || 'matricial2';
|
||||||
|
process.env.PRINTER_TIMEOUT_MS = process.env.PRINTER_TIMEOUT_MS || '60000';
|
||||||
|
|
||||||
|
process.env.PORT = process.env.PORT || '3002';
|
||||||
|
process.env.TEST_BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT}`;
|
||||||
|
require('../src/server.js');
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const base = process.env.TEST_BASE_URL || 'http://localhost:3002';
|
||||||
|
await sleep(500);
|
||||||
|
const widths = [576, 512, 448, 384];
|
||||||
|
for (const w of widths) {
|
||||||
|
try {
|
||||||
|
console.log(`Intentando imprimir referencia.jpeg con width=${w}`);
|
||||||
|
const resp = await axios.post(base + '/api/print/image', { path: 'referencia.jpeg', width: w, threshold: 128, mode: 'mono' }, { timeout: 180000 });
|
||||||
|
console.log(resp.data);
|
||||||
|
if (resp.data && resp.data.ok) {
|
||||||
|
console.log('Impresión OK con width=', w);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error al imprimir con width', w, e.message);
|
||||||
|
}
|
||||||
|
await sleep(800);
|
||||||
|
}
|
||||||
|
console.error('No se pudo confirmar OK en ninguna anchura. Revisa el resultado visual o ajusta el umbral.');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => { console.error(e); process.exit(1); });
|
||||||
80
scripts/print_template.js
Normal file
80
scripts/print_template.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Prints from a JSON template spec using backend /api/print
|
||||||
|
// Usage: node scripts/print_template.js templates/referencia.json
|
||||||
|
|
||||||
|
process.env.PRINTER_HOST = process.env.PRINTER_HOST || '192.168.87.147';
|
||||||
|
process.env.PRINTER_DEVICE_ID = process.env.PRINTER_DEVICE_ID || 'matricial2';
|
||||||
|
process.env.PRINTER_TIMEOUT_MS = process.env.PRINTER_TIMEOUT_MS || '60000';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Allow running standalone or with server already running
|
||||||
|
if (!process.env.TEST_BASE_URL) {
|
||||||
|
process.env.PORT = process.env.PORT || '3003';
|
||||||
|
process.env.TEST_BASE_URL = `http://localhost:${process.env.PORT}`;
|
||||||
|
require('../src/server.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
function padRight(str, len) { str = String(str); return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length); }
|
||||||
|
function padLeft(str, len) { str = String(str); return str.length >= len ? str.slice(0, len) : ' '.repeat(len - str.length) + str; }
|
||||||
|
|
||||||
|
function buildOpsFromSpec(spec) {
|
||||||
|
const ops = [];
|
||||||
|
const widthChars = spec.widthChars || 42; // default receipt width (monospace)
|
||||||
|
const hrChar = spec.hrChar || '-';
|
||||||
|
|
||||||
|
const addLines = (lines, align, options = {}) => {
|
||||||
|
if (align) ops.push({ op: 'textAlign', 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 });
|
||||||
|
for (const ln of lines) ops.push({ op: 'text', value: String(ln) });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sec of spec.sections || []) {
|
||||||
|
if (sec.lines) {
|
||||||
|
addLines(sec.lines, sec.align, sec);
|
||||||
|
} else if (sec.hr) {
|
||||||
|
ops.push({ op: 'textAlign', align: 'left' });
|
||||||
|
ops.push({ op: 'text', value: hrChar.repeat(widthChars) });
|
||||||
|
} else if (sec.columns) {
|
||||||
|
const widths = sec.columns.widths || [];
|
||||||
|
const rows = sec.columns.rows || [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const cols = [];
|
||||||
|
for (let i = 0; i < widths.length; i++) {
|
||||||
|
const w = widths[i];
|
||||||
|
const v = row[i] == null ? '' : String(row[i]);
|
||||||
|
// left pad for last col if numeric
|
||||||
|
if (i === widths.length - 1 && /^[0-9$.,\s-]+$/.test(v)) cols.push(padLeft(v, w));
|
||||||
|
else cols.push(padRight(v, w));
|
||||||
|
}
|
||||||
|
ops.push({ op: 'textAlign', align: 'left' });
|
||||||
|
ops.push({ op: 'text', value: cols.join('') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sec.feedLines) ops.push({ op: 'feedLine', line: sec.feedLines });
|
||||||
|
if (sec.cut) ops.push({ op: 'cut', type: sec.cut });
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const jsonPath = process.argv[2];
|
||||||
|
if (!jsonPath) throw new Error('Template JSON path required');
|
||||||
|
const spec = JSON.parse(fs.readFileSync(path.resolve(jsonPath), 'utf-8'));
|
||||||
|
|
||||||
|
const ops = buildOpsFromSpec(spec);
|
||||||
|
const base = process.env.TEST_BASE_URL;
|
||||||
|
const resp = await axios.post(base + '/api/print', { operations: ops }, { timeout: 180000 });
|
||||||
|
console.log(resp.data);
|
||||||
|
if (!resp.data || resp.data.ok !== true) {
|
||||||
|
throw new Error('Printer did not confirm success.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
103
scripts/run_tests.js
Normal file
103
scripts/run_tests.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
Progressive printer tests via backend endpoints.
|
||||||
|
- Starts the Express server by requiring it
|
||||||
|
- Sends simple -> complex print jobs to matricial2
|
||||||
|
*/
|
||||||
|
process.env.PRINTER_HOST = process.env.PRINTER_HOST || '192.168.87.147';
|
||||||
|
process.env.PRINTER_DEVICE_ID = process.env.PRINTER_DEVICE_ID || 'matricial2';
|
||||||
|
process.env.PRINTER_TIMEOUT_MS = process.env.PRINTER_TIMEOUT_MS || '60000';
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
require('../src/server.js');
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const base = 'http://localhost:3000';
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'dryRun minimo',
|
||||||
|
path: '/api/print',
|
||||||
|
body: { operations: [{ op: 'text', value: 'Hola DRYRUN' }], dryRun: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'texto simple',
|
||||||
|
path: '/api/print/text',
|
||||||
|
body: { text: 'Test 1: texto simple', options: { feedLines: 1 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'alineado + corte',
|
||||||
|
path: '/api/print/text',
|
||||||
|
body: { text: 'Test 2: center + cut', options: { align: 'center', feedLines: 1, cut: 'feed' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'fuente y tamaño',
|
||||||
|
path: '/api/print/text',
|
||||||
|
body: { text: 'Test 3: font_b w2 h2', options: { font: 'font_b', size: { width: 2, height: 2 }, style: { em: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'lote con feeds',
|
||||||
|
path: '/api/print',
|
||||||
|
body: { operations: [
|
||||||
|
{ op: 'text', value: 'Test 4: linea 1' },
|
||||||
|
{ op: 'feedLine', line: 1 },
|
||||||
|
{ op: 'text', value: 'Test 4: linea 2' }
|
||||||
|
]}
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'barcode EAN13',
|
||||||
|
path: '/api/print',
|
||||||
|
body: { operations: [
|
||||||
|
{ op: 'textAlign', align: 'center' },
|
||||||
|
{ op: 'text', value: 'Test 5: EAN13' },
|
||||||
|
{ op: 'feedLine', line: 1 },
|
||||||
|
{ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 },
|
||||||
|
{ op: 'feedLine', line: 2 }
|
||||||
|
]}
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'QR code',
|
||||||
|
path: '/api/print',
|
||||||
|
body: { operations: [
|
||||||
|
{ op: 'textAlign', align: 'center' },
|
||||||
|
{ op: 'text', value: 'Test 6: QR' },
|
||||||
|
{ op: 'feedLine', line: 1 },
|
||||||
|
{ op: 'qrcode', data: 'https://example.com/test6', model: 'qrcode_model_2', level: 'level_m', size: 6 },
|
||||||
|
{ op: 'feedLine', line: 2 }
|
||||||
|
]}
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
name: 'abrir cajon',
|
||||||
|
path: '/api/print/pulse',
|
||||||
|
body: { drawer: 'drawer_1', time: 'pulse_200' }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const s of steps) {
|
||||||
|
try {
|
||||||
|
console.log(`\n==> ${s.name}`);
|
||||||
|
const resp = await axios.post(base + s.path, s.body, { timeout: 120000 });
|
||||||
|
console.log({ status: resp.status, data: resp.data });
|
||||||
|
if (resp.data && resp.data.ok === false) {
|
||||||
|
console.warn('WARN: backend reported not ok:', resp.data.code || resp.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ERROR in step', s.name, e.message);
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nPruebas completadas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(500).then(run);
|
||||||
|
|
||||||
37
scripts/test_all.js
Normal file
37
scripts/test_all.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Orchestrator: starts the server and runs separated tests sequentially,
|
||||||
|
// waiting for confirmation (ok: true) from the printer between steps.
|
||||||
|
|
||||||
|
process.env.PRINTER_HOST = process.env.PRINTER_HOST || '192.168.87.147';
|
||||||
|
process.env.PRINTER_DEVICE_ID = process.env.PRINTER_DEVICE_ID || 'matricial2';
|
||||||
|
process.env.PRINTER_TIMEOUT_MS = process.env.PRINTER_TIMEOUT_MS || '60000';
|
||||||
|
process.env.PORT = process.env.PORT || '3001';
|
||||||
|
process.env.TEST_BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT}`;
|
||||||
|
|
||||||
|
require('../src/server.js');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const tests = [
|
||||||
|
require('./tests/01_text_simple'),
|
||||||
|
require('./tests/02_align_cut'),
|
||||||
|
require('./tests/03_font_size'),
|
||||||
|
require('./tests/04_batch_feeds'),
|
||||||
|
require('./tests/05_barcode'),
|
||||||
|
require('./tests/06_qr'),
|
||||||
|
require('./tests/07_pulse'),
|
||||||
|
];
|
||||||
|
|
||||||
|
await sleep(500); // give server time to bind
|
||||||
|
|
||||||
|
for (let i = 0; i < tests.length; i++) {
|
||||||
|
const fn = tests[i];
|
||||||
|
console.log(`\n>>> Running test ${i + 1}`);
|
||||||
|
await fn();
|
||||||
|
await sleep(800); // small gap between prints
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll tests completed successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => { console.error(e); process.exit(1); });
|
||||||
14
scripts/tests/01_text_simple.js
Normal file
14
scripts/tests/01_text_simple.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print/text', { text: 'Test 1: texto simple', options: { feedLines: 1 } });
|
||||||
|
console.log('01_text_simple ->', data);
|
||||||
|
ensureOk(data, '01_text_simple');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
14
scripts/tests/02_align_cut.js
Normal file
14
scripts/tests/02_align_cut.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print/text', { text: 'Test 2: center + cut', options: { align: 'center', feedLines: 1, cut: 'feed' } });
|
||||||
|
console.log('02_align_cut ->', data);
|
||||||
|
ensureOk(data, '02_align_cut');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
17
scripts/tests/03_font_size.js
Normal file
17
scripts/tests/03_font_size.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print/text', {
|
||||||
|
text: 'Test 3: font_b w2 h2',
|
||||||
|
options: { font: 'font_b', size: { width: 2, height: 2 }, style: { em: true }, feedLines: 1 }
|
||||||
|
});
|
||||||
|
console.log('03_font_size ->', data);
|
||||||
|
ensureOk(data, '03_font_size');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
21
scripts/tests/04_batch_feeds.js
Normal file
21
scripts/tests/04_batch_feeds.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print', {
|
||||||
|
operations: [
|
||||||
|
{ op: 'text', value: 'Test 4: linea 1' },
|
||||||
|
{ op: 'feedLine', line: 1 },
|
||||||
|
{ op: 'text', value: 'Test 4: linea 2' },
|
||||||
|
{ op: 'feedLine', line: 1 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log('04_batch_feeds ->', data);
|
||||||
|
ensureOk(data, '04_batch_feeds');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
22
scripts/tests/05_barcode.js
Normal file
22
scripts/tests/05_barcode.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print', {
|
||||||
|
operations: [
|
||||||
|
{ op: 'textAlign', align: 'center' },
|
||||||
|
{ op: 'text', value: 'Test 5: EAN13' },
|
||||||
|
{ op: 'feedLine', line: 1 },
|
||||||
|
{ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 },
|
||||||
|
{ op: 'feedLine', line: 2 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log('05_barcode ->', data);
|
||||||
|
ensureOk(data, '05_barcode');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
22
scripts/tests/06_qr.js
Normal file
22
scripts/tests/06_qr.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print', {
|
||||||
|
operations: [
|
||||||
|
{ op: 'textAlign', align: 'center' },
|
||||||
|
{ op: 'text', value: 'Test 6: QR' },
|
||||||
|
{ op: 'feedLine', line: 1 },
|
||||||
|
{ op: 'qrcode', data: 'https://example.com/test6', model: 'qrcode_model_2', level: 'level_m', size: 6 },
|
||||||
|
{ op: 'feedLine', line: 2 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log('06_qr ->', data);
|
||||||
|
ensureOk(data, '06_qr');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
14
scripts/tests/07_pulse.js
Normal file
14
scripts/tests/07_pulse.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { post, ensureOk } = require('./common');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await post('/api/print/pulse', { drawer: 'drawer_1', time: 'pulse_200' });
|
||||||
|
console.log('07_pulse ->', data);
|
||||||
|
ensureOk(data, '07_pulse');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
|
|
||||||
19
scripts/tests/common.js
Normal file
19
scripts/tests/common.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const baseURL = process.env.TEST_BASE_URL || 'http://localhost:3000';
|
||||||
|
const client = axios.create({ baseURL, timeout: 120000 });
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
const res = await client.post(path, body);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOk(data, stepName) {
|
||||||
|
if (!data || data.ok !== true) {
|
||||||
|
const msg = `Printer did not confirm success on step: ${stepName} -> ${JSON.stringify(data)}`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { post, ensureOk };
|
||||||
|
|
||||||
191
src/client/App.vue
Normal file
191
src/client/App.vue
Normal 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
5
src/client/main.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
|
|
||||||
124
src/eposBuilder.js
Normal file
124
src/eposBuilder.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/\"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
158
src/server.js
Normal 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}`);
|
||||||
|
});
|
||||||
13
templates/referencia.json
Normal file
13
templates/referencia.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"widthChars": 42,
|
||||||
|
"sections": [
|
||||||
|
{ "align": "center", "font": "font_b", "size": { "width": 2, "height": 2 }, "lines": ["AJUSTAR_TITULO"] },
|
||||||
|
{ "align": "left", "lines": ["AJUSTAR_SUBTITULO_O_DIRECCION", "FECHA_Y_HORA"] },
|
||||||
|
{ "hr": true },
|
||||||
|
{ "columns": { "widths": [24, 8, 10], "rows": [["ITEM", "CANT", "IMPORTE"]] } },
|
||||||
|
{ "hr": true },
|
||||||
|
{ "align": "right", "lines": ["TOTAL 999,99"] },
|
||||||
|
{ "feedLines": 2, "cut": "feed" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
21
vite.config.js
Normal file
21
vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3030',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'public',
|
||||||
|
emptyOutDir: false
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user