diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/lib/brother.js b/lib/brother.js new file mode 100644 index 0000000..f47373c --- /dev/null +++ b/lib/brother.js @@ -0,0 +1,246 @@ +const ipp = require('ipp'); +const util = require('util'); +const fs = require('fs'); +const pngparse = require('pngparse'); + +function wait(time) { + return new Promise(resolve => setTimeout(resolve, time)); +} + +async function doPrint(printerUrl, bufferToBePrinted) { + const printer = ipp.Printer(printerUrl); + const execute = util.promisify(printer.execute.bind(printer)); + const printerStatus = await execute('Get-Printer-Attributes', null); + + if (printerStatus['printer-attributes-tag']['printer-state'] === 'idle') { + // printer ready to work + const res = await execute( + 'Print-Job', + { + 'operation-attributes-tag': { + 'requesting-user-name': 'mobile', + 'job-name': 'label', + 'document-format': 'application/octet-stream', + }, + 'job-attributes-tag': { + copies: 1, + sides: 'one-sided', + 'orientation-requested': 'landscape', + }, + data: bufferToBePrinted, + }, + ); + + if (res.statusCode === 'successful-ok' || + res.statusCode === 'successful-ok-ignored-or-substituted-attributes') + { + const jobId = res['job-attributes-tag']['job-id']; + let tries = 0; + let job; + + await wait(500); + while (tries <= 50) { + tries += 1; + + // eslint-disable-next-line no-await-in-loop + job = await execute( + 'Get-Job-Attributes', + { 'operation-attributes-tag': { 'job-id': jobId } }, + ); + + if (job && job['job-attributes-tag']['job-state'] === 'completed') { + return job; + } + + // eslint-disable-next-line no-await-in-loop + await wait(1000); + } + + await execute('Cancel-Job', { + 'operation-attributes-tag': { + // "job-uri":jobUri, //uncomment this + //* / + 'printer-uri': printer.uri, // or uncomment this two lines - one of variants should work!!! + 'job-id': job['job-attributes-tag']['job-id'], + //* / + }, + }); + + console.log(`Job with id ${job['job-attributes-tag']['job-id']}is being canceled`); + throw new Error('Job is canceled - too many tries and job is not printed!'); + } else { + console.log(res); + throw new Error('Error sending job to printer!'); + } + } else { + throw new Error(`Printer ${printerStatus['printer-attributes-tag']['printer-name']} is not ready!`); + } +}; + +function convertToBlackAndWhiteMatrixImage(image, options) { + // convert image to matrix of pixels: + let rows = []; + + for (let y = 0; y < image.height; y++) { + let cols = []; + for (let x = 0; x < image.width; x++) { + let pos = x + image.width*y; + + + pos = pos * image.channels; + let pixel = 0; // white = 0, black = 1 + + // console.log(image.data[pos], image.data[pos+1], image.data[pos+2], image.data[pos+3]); + let threshold = options.blackwhiteThreshold; + let gray; + + // 1 channel : grayscale + // 2 channels: grayscale + alpha + // 3 channels: RGB + // 4 channels: RGBA + switch(image.channels) { + case 1: + if(image.data[pos] < threshold) pixel = 1; + break; + + case 2: + gray = image.data[pos] * image.data[pos+1]/255; + if(gray < threshold) pixel = 1; + break; + + case 3: + gray = 0.21*image.data[pos] + 0.72*image.data[pos+1] + 0.07*image.data[pos+2]; + if(gray < threshold) pixel = 1; + break; + + case 4: + gray = (0.21*image.data[pos] + 0.72*image.data[pos+1] + 0.07*image.data[pos+2]) * image.data[pos+3]/255; + if(gray < threshold) pixel = 1; + break; + } + + cols.push(pixel); + } + rows.push(cols); + } + + return { + height: image.height, + width: image.width, + data: rows + }; +} + +function rotateMatrixImage(bwMatrixImage) { + let rows = []; + for (let x = 0; x < bwMatrixImage.width; x++) { + let cols = []; + for (let y = bwMatrixImage.height - 1; y >= 0; y--) { + cols.push(bwMatrixImage.data[y][x]); + } + rows.push(cols); + } + + // noinspection JSSuspiciousNameCombination + return { + height: bwMatrixImage.width, + width: bwMatrixImage.height, + data: rows + }; +} + +function convertImageToDotlabel(bwMatrixImage) { + + // build header data for image + let data = [ + Buffer.alloc(400), // invalidate + Buffer.from([0x1b, 0x40]), // initialize + Buffer.from([0x1b, 0x69, 0x61, 0x01]), // switch to raster mode + Buffer.from([0x1b, 0x69, 0x21, 0x00]), // status notification + Buffer.from([0x1b, 0x69, 0x7a, 0x86, 0x0a, 0x3e, 0x00, 0xe0, 0x03, 0x00, 0x00, 0x00, 0x00]), // 62mm continuous + Buffer.from([0x1b, 0x69, 0x4d, 0x40]), // select auto cut + Buffer.from([0x1b, 0x69, 0x41, 0x01]), // auto cut for each sheet + Buffer.from([0x1b, 0x69, 0x4b, 0x08]), // select cut at end + Buffer.from([0x1b, 0x69, 0x64, 0x23, 0x00]), // 35 dots margin + Buffer.from([0x4d, 0x00]), // disable compression + ]; + + // iterate over matrix imag + for (let y = 0; y < bwMatrixImage.height; y++) { + // each row has 3 bytes for the command and 90 bytes for data + let rowBuffer = Buffer.alloc(93); + + // command is 0x67 0x00 0x90 + rowBuffer[0] = 0x67; + rowBuffer[2] = 0x5A; // 90 + for (let x = 0; x < bwMatrixImage.width; x++) { + if(bwMatrixImage.data[y][x] == 1) { + // calculate current byte and bit + let byteNum = 93 - Math.floor(x / 8 + 3); + let bitOffset = x % 8; + // write data to buffer (which is currently 0x00-initialized) + rowBuffer[byteNum] |= (1 << bitOffset); + } + } + + data.push(rowBuffer); + } + + // end label with Z: + data.push(Buffer.from([0x1A])); + + // concat all buffers + let buf = Buffer.concat(data); + //console.log(buf.length, "length"); + return buf; +} + + +async function convert(img, options) { + // get options + let defaultOptions = { + landscape: false, + blackwhiteThreshold: 128 + }; + if (options == null) options = defaultOptions; + if (!options.landscape) options.landscape = defaultOptions.landscape; + if (!options.blackwhiteThreshold) options.blackwhiteThreshold = defaultOptions.blackwhiteThreshold; + + // image width cannot be more than 720 pixels + // can only store 90 bytes in a row with 8 pixels per byte so that's 720 pixels + if (!options.landscape) { + if(img.width > 720) throw new Error('Width cannot be more than 720 pixels'); + } else { + if(img.height > 720) throw new Error('Height cannot be more than 720 pixels'); + } + + // convert to black and white pixel matrix image (pbm style): + let bwMatrixImage = convertToBlackAndWhiteMatrixImage(img, options); + + // rotate image if landscape mode is requested + if(options.landscape){ + bwMatrixImage = rotateMatrixImage(bwMatrixImage); + } + + // convert to 'label image' or something that the label printer understands: + return convertImageToDotlabel(bwMatrixImage); +} + +module.exports = { + printPngFile: async function(printerUrl, filename, options) { + // read PNG file + let parseFile = util.promisify(pngparse.parseFile); + let img = await parseFile(filename); + + let printData = await convert(img, options); + return await doPrint(printerUrl, printData); + }, + printPngBuffer: async function (printerUrl, buffer, options) { + // read PNG buffer + let parseBuffer = util.promisify(pngparse.parseBuffer); + let img = await parseBuffer(filename); + + let printData = await convert(img, options); + return await doPrint(printerUrl, printData); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..79a789f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "brother-label-printer", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipp/-/ipp-2.0.1.tgz", + "integrity": "sha512-p5dO2BXAVDnkv6IhUBupwydkq5/uw+DE+MGXnYzziNK1AtuLgbT9dFfJ3f8pA+J21n43TYipm6et/hTDEFJU/A==" + }, + "pngparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pngparse/-/pngparse-2.0.1.tgz", + "integrity": "sha1-hoUt5N40n077HoUudSVlXlrF37g=" + }, + "util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "requires": { + "inherits": "2.0.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1fca55 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "brother-label-printer", + "version": "0.0.1", + "description": "Create raster files to print via a Brother label printer", + "main": "lib/brother.js", + "author": "Dennis Riehle", + "license": "AGPL-3.0-or-later", + "dependencies": { + "ipp": "^2.0.1", + "pngparse": "^2.0.1", + "util": "^0.11.1" + } +} diff --git a/samples/name-tag-large.png b/samples/name-tag-large.png new file mode 100644 index 0000000..75ecdf4 Binary files /dev/null and b/samples/name-tag-large.png differ diff --git a/samples/name-tag-large.prn b/samples/name-tag-large.prn new file mode 100644 index 0000000..622621f Binary files /dev/null and b/samples/name-tag-large.prn differ diff --git a/samples/name-tag-small.png b/samples/name-tag-small.png new file mode 100644 index 0000000..790245a Binary files /dev/null and b/samples/name-tag-small.png differ diff --git a/samples/name-tag-small.prn b/samples/name-tag-small.prn new file mode 100644 index 0000000..6d8de54 Binary files /dev/null and b/samples/name-tag-small.prn differ diff --git a/samples/print-samples.js b/samples/print-samples.js new file mode 100644 index 0000000..ae6e360 --- /dev/null +++ b/samples/print-samples.js @@ -0,0 +1,7 @@ +const brother = require('../index.js'); + +(async() => { + const printerUrl = 'http://192.168.178.71:631/ipp/print'; + await brother.printPngFile(printerUrl, './name-tag-small.png', {landscape: false}); + await brother.printPngFile(printerUrl, './name-tag-large.png', {landscape: true}); +})(); \ No newline at end of file