Skip to content

Instantly share code, notes, and snippets.

@skylarmt
Last active October 16, 2024 22:42
Node.js code to take an image (via Jimp library) and convert it to label printer commands for ZPL, EPL2, or TSPL
/**
* Get the raw text commands to send to the printer for EPL, TSPL, or ZPL.
* @param {jimp image} image, 300 DPI
* @param {string} printerLang "epl2", "tspl2", or "zpl"
* @param {number} printerDpi 200 or 300, don't use 203
* @param {number} labelWidthMM
* @param {number} labelHeightMM
* @param {number} labelGapMM Gap between labels on roll
* @param {number} labelDensity Print darkness
* @param {number} labelVerticalOffset Adjust the positioning if the top or bottom of the label are cut off or go to the next label
* @param {string} mediaType Set the label gap sense type: "web", "mark", "continuous"
* @returns {array} Things to write to the printer as-is and in order to make it print correctly. See sendRawDataToPrintQueue
*/
function getRawPrinterCode(image, printerLang, printerDpi, labelWidthMM, labelHeightMM, labelGapMM, labelDensity, labelVerticalOffset, mediaType) {
image.greyscale();
if (printerDpi == 200) {
image.resize(image.bitmap.width / 1.5, image.bitmap.height / 1.5);
}
if (typeof mediaType != "string") {
mediaType = "web";
}
// Figure out how many more pixels we need per row to make the row have a whole number of bytes
let imageRowLength = image.bitmap.width;
let requiredRowLength = imageRowLength + ((Math.ceil(image.bitmap.width / 8) - (image.bitmap.width / 8)) * 8);
if (imageRowLength != requiredRowLength) {
console.log("Resizing image to be even number of bytes wide");
const Jimp = require("jimp");
var newImg = new Jimp(requiredRowLength, image.bitmap.height, "#FFFFFF");
newImg.blit(image, 0, 0, 0, 0, image.bitmap.width, image.bitmap.height);
image = newImg;
}
// convert to binary
let threshold = 127;
let columnIndex = 0;
let binaryImg = new Uint8Array((image.bitmap.width * image.bitmap.height) / 8);
let byteIndex = 0;
let byte = [];
for (let i = 0; i < image.bitmap.data.length - 4; i += 4) {
let r = image.bitmap.data[i];
let g = image.bitmap.data[i + 1];
let b = image.bitmap.data[i + 2];
let a = image.bitmap.data[i + 3];
if (r + g + b > threshold * 3 || a < threshold) {
if (printerLang == "zpl") {
byte.push(0);
} else {
byte.push(1);
}
} else {
if (printerLang == "zpl") {
byte.push(1);
} else {
byte.push(0);
}
}
columnIndex++;
while (columnIndex == imageRowLength && byte.length < 8) {
byte.push(printerLang == "zpl" ? 1 : 0);
}
if (byte.length == 8) {
binaryImg[byteIndex] = parseInt(byte.join(""), 2);
byte = [];
byteIndex++;
}
}
// center image on label
let labelWidthInDots = labelWidthMM * (printerDpi == 200 ? 8 : 11.8);
let labelHeightInDots = labelHeightMM * (printerDpi == 200 ? 8 : 11.8);
let labelGapInDots = labelGapMM * (printerDpi == 200 ? 8 : 11.8);
let xOffset = Math.max(0, Math.ceil((labelWidthInDots - image.bitmap.width) / 2));
let yOffset = Math.max(0, Math.ceil(((labelHeightInDots - image.bitmap.height) / 2) + (labelVerticalOffset * (printerDpi == 200 ? 8 : 11.8))));
if (printerLang == "epl2") {
var mediaGapCommand = `Q${labelHeightInDots},${labelGapInDots}`;
switch (mediaType) {
case "mark":
mediaGapCommand = `Q${labelHeightInDots},B${labelGapInDots},0`;
break;
case "continuous":
mediaGapCommand = `Q${labelHeightInDots},0`;
break;
case "web":
case "gap":
default:
mediaGapCommand = `Q${labelHeightInDots},${labelGapInDots}`;
break;
}
return [`N\nq${labelWidthInDots}\n${mediaGapCommand}\nD${Math.min(15, labelDensity)}\nGW${xOffset},${yOffset},${Math.ceil(image.bitmap.width / 8)},${image.bitmap.height},`,
binaryImg,
"\nP1\n"
];
} else if (printerLang == "tspl2") {
var mediaGapCommand = `GAP ${labelGapMM} mm,0 mm`;
switch (mediaType) {
case "mark":
mediaGapCommand = `BLINE ${labelGapMM} mm,${labelGapMM} mm`;
break;
case "continuous":
mediaGapCommand = `GAP 0,0\nSET CUTTER 1`; // Also enable label cutter if present
break;
case "web":
case "gap":
default:
mediaGapCommand = `GAP ${labelGapMM} mm,0 mm`;
break;
}
/*
* SIZE: set label size
* CLS: clear image buffer
* LIMITFEED: max label length before stopping feed/erroring out
* SPEED: print speed, inches per second
* DENSITY: print darkness (0-15)
* BITMAP: draw image x,y,width in bytes,height in rows/dots,mode (0=overwrite),bitmap data (binary)
* PRINT: print the label (1 label set, 1 copy)
*/
return [
`SIZE ${labelWidthMM} mm,${labelHeightMM} mm\nCLS\n${mediaGapCommand}\nLIMITFEED 200 mm\nSPEED 4\nDENSITY ${Math.min(15, labelDensity)}\nBITMAP ${xOffset},${yOffset},${Math.ceil(image.bitmap.width / 8)},${image.bitmap.height},0,`,
binaryImg,
"\nPRINT 1,1\n"
];
} else if (printerLang == "zpl") {
let hexImg = Buffer.from(binaryImg).toString('hex').toUpperCase();
// Do some compression on repeating Fs and 0s, shrinks label size to about 10-30% of original byte array size so it transmits and prints faster
let hexCompressedImg = hexImg;
for (let i = 0; i < 2; i++) { // Do a couple compression passes, save another ~100 bytes or so
hexCompressedImg = hexCompressedImg.replaceAll("0000", "J0").replaceAll("FFFF", "JF").replaceAll("J0J0", "N0").replaceAll("JFJF", "NF");
hexCompressedImg = hexCompressedImg.replaceAll("N0N0", "V0").replaceAll("NFNF", "VF").replaceAll("V0V0V0V0V0", "j0").replaceAll("VFVFVFVFVF", "jF");
hexCompressedImg = hexCompressedImg.replaceAll("j0j0", "n0").replaceAll("jFjF", "nF").replaceAll("n0n0", "v0").replaceAll("nFnF", "vF");
hexCompressedImg = hexCompressedImg.replaceAll("V0V0N0", "h0").replaceAll("VFVFNF", "hF");
hexCompressedImg = hexCompressedImg.replaceAll("v0v0v0v0v0", "z0z0z0z0").replaceAll("vFvFvFvFvF", "zFzFzFzF");
hexCompressedImg = hexCompressedImg.replaceAll("V0000", "Y0").replaceAll("VFFFF", "YF").replaceAll("V000", "X0").replaceAll("VFFF", "XF").replaceAll("V00", "W0").replaceAll("VFF", "WF");
hexCompressedImg = hexCompressedImg.replaceAll("J0000", "M0").replaceAll("JFFFF", "MF").replaceAll("J000", "L0").replaceAll("JFFF", "LF").replaceAll("J00", "K0").replaceAll("JFF", "KF");
hexCompressedImg = hexCompressedImg.replaceAll("N0000", "Q0").replaceAll("NFFFF", "QF").replaceAll("N000", "P0").replaceAll("NFFF", "PF").replaceAll("N00", "O0").replaceAll("NFF", "OF");
hexCompressedImg = hexCompressedImg.replaceAll("V0V0V0V0J0", "i0N0").replaceAll("VFVFVFVFJF", "iFNF");
hexCompressedImg = hexCompressedImg.replaceAll(/(?<![g-zG-Y])000/g, "I0").replaceAll(/(?<![g-zG-Y])FFF/g, "IF");
hexCompressedImg = hexCompressedImg.replaceAll("j0V0V0", "k0R0").replaceAll("jFVFVF", "kFRF");
hexCompressedImg = hexCompressedImg.replaceAll("N0J0", "R0").replaceAll("NFJF", "RF");
hexCompressedImg = hexCompressedImg.replaceAll("N0M0", "U0").replaceAll("NFMF", "UF");
hexCompressedImg = hexCompressedImg.replaceAll("N0L0", "T0").replaceAll("NFLF", "TF");
hexCompressedImg = hexCompressedImg.replaceAll("V0V0V0", "h0N0").replaceAll("VFVFVF", "hFNF");
hexCompressedImg = hexCompressedImg.replaceAll("R00", "T0").replaceAll("RFF", "TF");
}
/*
* ^XA: Start label
* ^PO: Set print orientation (^PON=normal, ^POI=invert)
* ^MU: Set units to dots, set DPI
* ^JZ: Enable (^JZY) or disable (^JZN) auto reprint after error
* ^MN: Set media edge detection (Y/W: web sensor, M: black mark sensor, N: continuous media)
* ^MM: Set after-print label treatment (T: facilitate tearing off label, C: cut label)
* ^MF: _F_eed to next label edge when when turning on and closing printer
* ^PW: Set print (label) width
* ^PR: Set print speed, blank label feed speed, and backfeed speed (inches per second)
* ~SD: Set label darkness (range 00-30)
* ^LH: Set label home position to 0,0 (top-left corner)
* ^FO: Set image offset relative to label home
* ^GF: Send graphics data (A=ASCII hex format),total binary byte count,total graphic byte count,bytes per row,data
* ^FS: End of image data field
* ^XZ: End label (trigger label printing)
*/
var mediaEdge = "W";
var afterPrint = "T"; // Move label to tear-off position after print
switch (mediaType) {
case "mark":
mediaEdge = "M";
afterPrint = "T";
break;
case "continuous":
mediaEdge = "N";
afterPrint = "C"; // Cut label after print
break;
case "web":
case "gap":
default:
mediaEdge = "W";
afterPrint = "T";
break;
}
var zpl = `^XA^PON^MUd,${printerDpi},${printerDpi}^JZY^MN${mediaEdge}^MM${afterPrint}^MFF,F^PW${labelWidthInDots}^PR3,6,3~SD${(labelDensity + "").padStart(2, '0')}^LH0,0^FO${xOffset},${yOffset}^GFA,${Math.ceil(image.bitmap.width / 8) * image.bitmap.height},${Math.ceil(image.bitmap.width / 8) * image.bitmap.height},${Math.ceil(image.bitmap.width / 8)},${hexCompressedImg}^FS^XZ`;
return [zpl];
}
return [""];
}
/**
* Send output of getRawPrinterCode to this to print it. The printer variable is the print queue name.
* For Windows, uses RawPrint.exe from here because Windows can't be normal: https://mendelson.org/windowsrawprint.html
*/
async function sendRawDataToPrintQueue(data, printer) {
const fs = require("fs/promises");
let filename = require('path').join(require('os').tmpdir(), 'print_' + require('crypto').randomBytes(8).toString('hex') + '.raw');
let file = await fs.open(filename, 'w');
for (var i = 0; i < data.length; i++) {
if (i == 0) {
await file.writeFile(data[i]);
} else {
await file.appendFile(data[i]);
}
}
await file.close();
if (process.platform == "linux") {
require('child_process').execSync(`lp -d "${printer}" -o raw "${filename}"`);
await fs.unlink(filename);
} else if (process.platform == "win32") {
require('child_process').exec(`RawPrint.exe "${printer}" "${filename}"`, {
windowsHide: true
}, function () {
fs.unlink(filename);
});
} else {
console.error("Unsupported printer configuration: Use a supported operating system or change your printer settings.");
}
}
@skylarmt
Copy link
Author

Use this code under your pick of license: GPL, LGPL, MPL, BSD, or Apache

Shameless plug for the software it came from: PostalPoint Retail Shipping, cross-platform Linux-first desktop application for businesses that want to quickly and easily sell postal services to the general public for an extra revenue stream: gas stations, hotels, copy and print shops, etc. Surprisingly lucrative!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment