diff --git a/README.md b/README.md index ff5fd5d..cd1ae97 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,44 @@ -# ZplPrinter +# Zpl - Esc/Pos Printer -Printer emulator for zpl rendering engine. The emulator is based on the [labelary](http://labelary.com/service.html) web service. You can configure print density, label size and the tcp server to listen for any incoming labels. +Printer emulator for Zpl, Esc/Pos rendering engine. The emulator is based on the [labelary](http://labelary.com/service.html) web service. +You can configure print density, label size and the tcp server to listen for any incoming labels. -[Releases](https://github.com/erikn69/ZplPrinter/releases/latest) +[Releases](https://github.com/erikn69/ZplEscPrinter/releases/latest) -## New in Version 2.0 +## New in Version 3.0 -The app now runs standalone via Electron and can be installed via the binaries/zips on the GitHub Releases page. It currently supports: +The app now emulates Epson ESC/POS. The emulator is based on the [receipt-print-hq/escpos-tools](https://github.com/receipt-print-hq/escpos-tools/blob/master/esc2html.php) repo by @mike42, and can be installed via the binaries/zips on the GitHub Releases page. It currently supports * Windows: - * Squarrel installer (zpl-printer-**version**-win32-**arch**-setup.exe) - * Portable runner (zpl-printer-portable-**version**.exe) - * NuGet package (zpl-printer-**version**-full.nupkg) + * Squarrel installer (zpl-escpos-printer-**version**-win32-**arch**-setup.exe) + * Portable runner (zpl-escpos-printer-portable-**version**.exe) + * NuGet package (zpl-escpos-printer-**version**-full.nupkg) * msi installer (TODO) * Linux: - * Rpm (zpl-printer-**version**.**arch**.rpm) - * Deb (zpl-printer\_**version**\_**arch**.deb) - * Zip (Zpl.Printer-linux-**arch**-**version**.zip) + * Rpm (zpl-escpos-printer-**version**.**arch**.rpm) + * Deb (zpl-escpos-printer\_**version**\_**arch**.deb) + * Zip (Zpl-EscPos.Printer-linux-**arch**-**version**.zip) * Mac: - * Zip (Zpl.Printer-darwin-**arch**-**version**.zip) + * Zip (Zpl-EscPos.Printer-darwin-**arch**-**version**.zip) + +## New in Version 2.0 + +The app now runs standalone via Electron. ## References * [ZPL Command Support](http://labelary.com/docs.html) * [ZPL Web Service](http://labelary.com/service.html) * [Electron](https://www.electronjs.org) * [Electron Forge](https://www.electronforge.io) +* [Esc/Pos receipt print tools](https://github.com/receipt-print-hq/escpos-tools) ## Release notes +### Version 3.0 +* **Refactor** Reworked code +* **New** Esc/Pos Support +* **Fix** Bug fixes + ### Version 2.2 * **Refactor** Reworked code * **Upgrade** Bump dependencies diff --git a/ZplPrinter/css/style.css b/ZplEscPrinter/css/style.css similarity index 81% rename from ZplPrinter/css/style.css rename to ZplEscPrinter/css/style.css index dbea1f0..cdb437b 100644 --- a/ZplPrinter/css/style.css +++ b/ZplEscPrinter/css/style.css @@ -5,7 +5,16 @@ display: none; } -img.thumbnail { +.panel-heading-blur { + background-color: #445da5 !important; + border-color: #215480 !important; +} + +.label-container { + height: 82vh; +} + +.label-zpl { margin: 5px 0px 20px 5px; } diff --git a/ZplPrinter/icons/Icon-128.png b/ZplEscPrinter/icons/Icon-128.png similarity index 100% rename from ZplPrinter/icons/Icon-128.png rename to ZplEscPrinter/icons/Icon-128.png diff --git a/ZplPrinter/icons/Icon-16-white.png b/ZplEscPrinter/icons/Icon-16-white.png similarity index 100% rename from ZplPrinter/icons/Icon-16-white.png rename to ZplEscPrinter/icons/Icon-16-white.png diff --git a/ZplPrinter/icons/Icon-16.png b/ZplEscPrinter/icons/Icon-16.png similarity index 100% rename from ZplPrinter/icons/Icon-16.png rename to ZplEscPrinter/icons/Icon-16.png diff --git a/ZplPrinter/icons/Icon-24.png b/ZplEscPrinter/icons/Icon-24.png similarity index 100% rename from ZplPrinter/icons/Icon-24.png rename to ZplEscPrinter/icons/Icon-24.png diff --git a/ZplPrinter/icons/Icon-32.png b/ZplEscPrinter/icons/Icon-32.png similarity index 100% rename from ZplPrinter/icons/Icon-32.png rename to ZplEscPrinter/icons/Icon-32.png diff --git a/ZplPrinter/icons/Icon-64.png b/ZplEscPrinter/icons/Icon-64.png similarity index 100% rename from ZplPrinter/icons/Icon-64.png rename to ZplEscPrinter/icons/Icon-64.png diff --git a/ZplPrinter/icons/icon-1024.png b/ZplEscPrinter/icons/icon-1024.png similarity index 100% rename from ZplPrinter/icons/icon-1024.png rename to ZplEscPrinter/icons/icon-1024.png diff --git a/ZplPrinter/icons/icon.svg b/ZplEscPrinter/icons/icon.svg similarity index 100% rename from ZplPrinter/icons/icon.svg rename to ZplEscPrinter/icons/icon.svg diff --git a/ZplEscPrinter/js/main.js b/ZplEscPrinter/js/main.js new file mode 100644 index 0000000..6ba32de --- /dev/null +++ b/ZplEscPrinter/js/main.js @@ -0,0 +1,484 @@ +const $ = global.$ = global.jQuery = require('jquery'); +const createPopper = global.createPopper = require('@popperjs/core'); +const Bootstrap = global.Bootstrap = require('bootstrap'); +const { ipcRenderer } = require('electron'); +const fs = require('fs');; +const net = require('net'); +const bootbox = require('bootbox'); + +let clientSocketInfo; +let server; +let configs = {}; + +const defaults = { + isZpl: true, + isOn: true, + density: '8', + width: '4', + height: '6', + unit: '1', + host: '127.0.0.1', + port: '9100', + bufferSize: '4096', + keepTcpSocket: true, + saveLabels: false, + filetype: '3', + path: null, + counter: 0 +}; + +$(function () { + $(window).bind('focus blur', function () { + $('#panel-head').toggleClass('panel-heading-blur'); + }); + + // todo only on first run + if (!global.localStorage.getItem('isOn')) { + Object.entries(defaults).forEach(function ([k, v]) { + if (global.localStorage.getItem(k)) { + global.localStorage.setItem(k, v); + } + }); + } +}); +$(document).ready(function () { + Object.keys(defaults).forEach(function (k) { + configs[k] = global.localStorage.getItem(k); + }); + + initEvents(); + initConfigs(); +}); +function getSize(width, height) { + const defaultWidth = 386; + + const factor = width / height; + return { + width: defaultWidth, + height: defaultWidth / factor + }; +} +async function saveLabel(blob, ext, counter) { + const fileName = `LBL${counter.padLeft(6)}.${ext}`; + const path = !configs.path || configs.path==='null' ? '' : configs.path.trimCharEnd('\\').trimCharEnd('/'); + + try { + fs.writeFileSync(path + '/' + fileName, typeof blob === 'string' ? blob : new Uint8Array(await blob.arrayBuffer())) + // file written successfully + notify('Label {0} saved in folder {1}'.format(fileName, path), 'floppy-saved', 'info', 1000); + } catch (err) { + console.error(err); + notify(`error in saving label to ${fileName} ${err.message}`, 'floppy-saved', 'danger', 0); + } + +} +async function fetchAndSavePDF(api_url, zpl, counter) { + let r1 = await fetch(api_url, { + method: "POST", + body: zpl, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/pdf' + } + }) + + if (!r1.ok || r1.status !== 200) { + console.log('error in fetching pdf', `status = ${r1.status}`, await r1.text(), `zpl=${zpl}`) + return + } + + let blob = await r1.blob() + await saveLabel(blob, 'pdf', counter); +} +// Display notification +// @param {String} text Notification text +// @param {Number} glyphicon Notification icon +// @param {String} type Notification type +// @param {Number} delay Notification fade out delay in ms +function notify(text, glyphicon, type, delay) { + const log = $('

' + text + '

').text(); + if (type === 'danger') { + console.error(log); + } else { + console.info(log); + } + + let el = $(``).appendTo('.bottom-left'); + setTimeout(function () { el.fadeOut(1000); }, delay || 2000); +} +async function zpl(data){ + try{ data = atob(data).trim(); }catch(e){} + const zpls = data.split(/\^XZ|\^xz/); + const factor = configs.unit === '1' ? 1 : (configs.unit === '2' ? 2.54 : (configs.unit === '3' ? 25.4 : 96.5)); + const width = parseFloat(configs.width) / factor; + const height = Math.round(parseFloat(configs.height) * 1000 / factor) / 1000; + + if (zpls.length > 1 && zpls[zpls.length - 1].trim() === '') { + zpls.pop(); + } + + for (let zpl of zpls) { + if (!zpl || !zpl.trim().length) { + console.warn(`zpl = '${zpl}', seems invalid`); + continue; + } + + zpl += '^XZ'; + + let api_url = atob('aHR0cDovL2FwaS5sYWJlbGFyeS5jb20vdjEvcHJpbnRlcnMvezB9ZHBtbS9sYWJlbHMvezF9eHsyfS8wLw==') + .format(configs.density, width>15.0 ? 15 : width, height); + let blob = await displayZplImage(api_url, zpl, width, height); + + if (![1, '1', true, 'true'].includes(configs.saveLabels)) { + return; + } + + console.info("configs", configs.saveLabels, "fileType", configs.filetype); + let counter = getCounter(); + if (configs.filetype === '1') { + await saveLabel(blob, "png", counter); + } + else if (configs.filetype === '2') { + await fetchAndSavePDF(api_url, zpl, counter); + } + else if (configs.filetype === '3') { + await saveLabel(zpl, "raw", counter); + } + } +} +async function escpos(data){ + try{ data = atob(data).trim(); }catch(e){ + try{ + data = JSON.parse( + '"' + data.replaceAll(/\\\\[u|U]0/g, '\\u0').replaceAll(/\\\\[x|X]/g, '\\x').replaceAll('"', '\\"') + .replaceAll('\n', '\\n').replaceAll('\t', '\\t').replaceAll('\r', '\\r') + '"' + ); + }catch(e){} + } + + if (!data || !data.trim().length) { + console.warn(`esc/pos = '${data}', seems invalid`); + return; + } + + const factor = configs.unit === '4' ? 1 : (configs.unit === '3' ? 379.921465 : (configs.unit === '2' ? 37.9921465 : 96.5)); + const width = Math.round(parseFloat(configs.width) * factor * 1000) / 1000; + + //console.log(data); + if (!data.replace("\u001B@",'')) { //empty + await displayEscPosLabel(btoa('
--- EMPTY / NO DATA ---

')); + } else if(["\u001Bp0
----- CASH REGISTER PULSE -----

')); + } else if(["\u001DVA\u0003"].includes(data.replace("\u001B@",''))) { //cut-paper + await displayEscPosLabel(btoa('
-------- PAPER CUT --------

')); + } else { + await $.post(atob('aHR0cHM6Ly90ZXN0LnJ1Ynlrcy5jb20vZXNjcG9zL2Jhc2U2NGh0bWwucGhw'), {esc: btoa(data), width: width}).done(function (response) { + //console.log('Data Loaded: ' + response); + displayEscPosLabel(response) + }); + } + + if ([1, '1', true, 'true'].includes(configs.saveLabels)) { + await saveLabel(data, "raw", getCounter()); + } +} +async function displayEscPosLabel (data){ + let frame = $(''); + frame.attr('srcdoc', atob(data)); + $('#label-esc').prepend(frame); + frame.on('load',function(){ + //console.log(frame.contents().find("body").height()); + frame.height((frame.contents().find("body").height() + 30) + 'px'); + $('#label-esc').css({'top': `-${frame.height() - 28}px`}).animate({'top': '0px'}, 1500); + }); + + frame.trigger('load'); +} +async function displayZplImage(api_url, zpl, width, height) { + let r1 = await fetch(api_url, { + method: "POST", + body: zpl, + headers: {'Content-Type': 'application/x-www-form-urlencoded'} + }) + + if (!r1.ok || r1.status !== 200) { + console.log('error in fetching image', `status = ${r1.status}`, await r1.text(), `zpl = ${zpl}`) + return + } + + const blob = await r1.blob() + const size = getSize(width, height); + const img = document.createElement('img'); + img.setAttribute('height', size.height); + img.setAttribute('width', size.width); + img.setAttribute('class', 'label-zpl border'); + img.onload = function (e) { + window.URL.revokeObjectURL(img.src); + }; + + img.src = window.URL.createObjectURL(blob); + + const offset = size.height + 20; + $('#label-zpl').prepend(img).css({'top': `-${offset}px`}).animate({'top': '0px'}, 1500); + + return blob; +} +function getCounter () { + let item = global.localStorage.getItem('counter') || '0'; + let counter = parseInt(item); + counter = isNaN(counter) ? 1 : counter; + console.log('counter?', item, counter); + global.localStorage.setItem('counter', `${++counter}`); + return counter; +} +// Start tcp server and listen on configuret host/port +function startTcpServer() { + if (server != undefined) { + return; + } + + server = net.createServer(); + server.listen(parseInt(configs.port), configs.host); + + notify('Printer started on Host: {0} Port: {1}'.format(configs.host, configs.port)); + + server.on('connection', function (sock) { + console.log('CONNECTED: ' + sock.remoteAddress + ':' + sock.remotePort); + clientSocketInfo = { + peerAddress: sock.remoteAddress, + peerPort: sock.remotePort + }; + sock.write(JSON.stringify({success: true}, 'text/html')); + + sock.on('data', async function (data) { + notify('{0} bytes received from Client: {1} Port: {2}'.format(data.length, clientSocketInfo.peerAddress, clientSocketInfo.peerPort), 'print', 'info', 1000); + + const regex = /POST.*\r\n\r\n/gs; + const code = String.fromCharCode.apply(null, new Uint8Array(data)).replace(regex,'').trim(); + if (code.includes('Host:') && code.includes('Connection: keep-alive') && code.includes('HTTP')) { + console.log('It\'s an ajax call'); + return; + } + + if (![1, '1', true, 'true'].includes(configs.keepTcpSocket)) { + toggleSwitch('#on_off'); + } + + try{ + if ($('#isZpl').is(':checked')) { + zpl(code); + } else { + escpos(code); + } + }catch(err){ + console.error(err); + notify('ERROR: {0}'.format(err.message), 'print', 'danger', 0) + } + }); + + }); +} +// Stop tcp server +function stopTcpServer() { + if (server == undefined) { + return; + } + server.close(); + notify('Printer stopped on {0} Port: {1}'.format(configs.host, configs.port)); + server = undefined; +} +// Init ui events +function initEvents() { + $('#isZpl, #isEsc').on('change', function () { + const zplPrinter = $('#isZpl').is(':checked'); + $('.panel-printer-title').text(zplPrinter ? 'ZPL' : 'Esc/Pos'); + $('.is-zpl')[zplPrinter ? 'show' : 'hide'](); + $('.is-esc')[zplPrinter ? 'hide' : 'show'](); + }); + + $('#isOn, #isOff').on('change', function () { + if ($('#isOn').is(':checked')) { + startTcpServer(); + } else { + stopTcpServer(); + } + }); + + $('#btn-remove').on('click', function () { + const zplPrinter = $('#isZpl').is(':checked'); + const labels = $(zplPrinter ? '.label-zpl' : '.label-esc'); + const size = labels.length; + + if (!size) { + return; + } + + const msg = '{0} {2} {1}'.format(size, size === 1 ? 'label' : 'labels', zplPrinter ? 'zpl' : 'esc/pos') + bootbox.confirm('Are you sure to remove {0}?'.format(msg), function (result) { + if (result) { + labels.remove(); + notify('{0} successfully removed.'.format(msg), 'trash', 'info'); + } + }); + }); + + $('#btn-close').on('click', function () { + global.localStorage.setItem('isZpl', $('#isZpl').is(':checked')); + global.localStorage.setItem('isOn', $('#isOn').is(':checked')); + stopTcpServer(); + window.close(); + }); + + $('#path').on('keydown', function (e) { + e.preventDefault(); + }); + + $('#configsForm').on('submit', function (e) { + e.preventDefault(); + saveConfigs(); + }); + + $('#testsForm').on('submit', function (e) { + e.preventDefault(); + const val = $('#test-data').val(); + const zplPrinter = $('#isZpl').is(':checked'); + $('#btn-close-test-md').trigger('click'); + notify('Printing raw ' + (zplPrinter ? 'zpl' : 'esc/pos')+ ' text test', 'print', 'info', 1000); + if (zplPrinter) { + zpl(val); + } else { + escpos(val); + } + }); + + $('.btn-close-test-md').on('click', function () { + $('#test-data').val(''); + }); + + $('#btn-run-test-hw').on('click', function () { + const data = $('#isZpl').is(':checked') + ? btoa('^xa^cfa,50^fo100,100^fdHello World^fs^xz') + : btoa("\u001B@\u001Ba\u0001\u001BE\u0001\u001B!VHello World\u001BE\u0000\u001Ba\u0000\u000A\u001DVA\u0003"); + $('#test-data').val(data); + $('#testsForm').submit(); + }); + + $('#btn-raw-file').on('click', function (e) { + e.preventDefault(); + ipcRenderer.send('select-file'); + }); + + $('#btn-setting').on('click', function () { + if ($('#isOn').is(':checked')) { + toggleSwitch('#on_off'); + } + initConfigs($('#settings-window')); + }); + + $('#saveLabels').on('change', function () { + $('#btn-filetype, #btn-path, #filetype, #path').prop('disabled', !$(this).is(':checked')); + }); + + $('.btn-close-save-settings').on('click', function () { + if (configs.keepTcpSocket && ! $('#isOn').is(':checked')) { + toggleSwitch('#on_off'); + } + }); + + $('#btn-path').on('click', function (e) { + e.preventDefault(); + ipcRenderer.send('select-dirs'); + }); + + ipcRenderer.on('selected-dirs', function (event, response) { + if (response && typeof Array.isArray(response) && response[0]) { + $('#path').val(response[0]); + } + }); + + ipcRenderer.on('selected-file', function (event, response) { + if (response && typeof Array.isArray(response) && response[0]) { + const base64 = fs.readFileSync(response[0]).toString('base64'); + $('#btn-close-test-md').trigger('click'); + if ($('#isZpl').is(':checked')) { + zpl(base64); + } else { + escpos(base64); + } + } + }); +} +// Toggle on/off switch +// @param {Dom Object} btn Button group to toggle +function toggleSwitch(group) { + let radios = $(group).find('input[type=radio]'); + let first = $(radios[0]).is(':checked'); + + $(radios[first?1:0]).prop('checked', true).trigger('change'); +} +// Save configs in local storage +function saveConfigs() { + for (let key in configs) { + let $el = $('#' + key); + + if (!$el.length) { + continue; + } + + if (['checkbox', 'radio'].includes(($el.attr('type') || '').toLowerCase())) { + configs[key] = $el.is(':checked'); + } else { + configs[key] = $el.val(); + } + } + + Object.entries(configs).forEach(function ([k, v]) { + global.localStorage.setItem(k, v); + }); + + notify('Printer settings changes successfully saved', 'cog', 'info'); + $('#btn-close-save-settings').trigger('click'); +} +// Init/load configs from local storage +function initConfigs(context) { + console.log('init', configs); + context = context || $('body'); + + for (let key in configs) { + let $el = context.find('#' + key); + + if (!$el.length) { + continue; + } + + if (['checkbox', 'radio'].includes(($el.attr('type') || '').toLowerCase())) { + $el.prop('checked', [true, 'true', 1, '1'].includes(configs[key])).trigger('change'); + } else { + $el.val(configs[key]); + } + } +} +// Prototype for string/number datatypes +String.prototype.format = function () { + let s = this, i = arguments.length; + while (i--) { + s = s.replace(new RegExp('\\{' + i + '\\}', 'gm'), arguments[i]); + } + return s; +}; +String.prototype.fixCharForRegex = function () { + let c = this + ''; + c = !c?' ' : (c==="]"? "\\]" : (c==="^"? "\\^" : ((c==="\\" ? "\\\\" : c)))); + return c; +}; +String.prototype.trimCharEnd = function (c) { + return this.replace(new RegExp('[' + ((c || '') + '').fixCharForRegex() + ']+$', 'g'), ''); +}; +Number.prototype.padLeft = function (width, character) { + character = character || '0'; + let str = this + ''; + return str.length >= width ? str : new Array(width - str.length + 1).join(character) + str; +} \ No newline at end of file diff --git a/ZplPrinter/main.html b/ZplEscPrinter/main.html similarity index 82% rename from ZplPrinter/main.html rename to ZplEscPrinter/main.html index f71e481..e7c7bce 100644 --- a/ZplPrinter/main.html +++ b/ZplEscPrinter/main.html @@ -8,15 +8,15 @@
-
+
Zpl Printer - EN Systems - +
-