Public Access
Add native Brother QL raster command builder
Implements the QL-8xx raster protocol in Node so badge printing no longer needs the Python brother_ql CLI. Command sequence verified byte-for-byte against a captured QL-8xx job.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Brother QL-8xx raster command builder.
|
||||
*
|
||||
* Byte-level command sequence follows Brother's "Raster Command Reference"
|
||||
* for the QL-800/810W/820NWB series, cross-checked against the reference
|
||||
* implementation in pklaus/brother_ql.
|
||||
*
|
||||
* The QL-820NWB print head is 720 dots wide (60.96 mm at 300 dpi). Every
|
||||
* raster line transmitted is therefore exactly 90 bytes before compression,
|
||||
* regardless of the media loaded. Narrower media just means more of those
|
||||
* dots fall outside the paper.
|
||||
*/
|
||||
|
||||
const PIXEL_WIDTH = 720;
|
||||
const BYTES_PER_ROW = PIXEL_WIDTH / 8; // 90
|
||||
const INVALIDATE_BYTES = 200;
|
||||
|
||||
// Endless (continuous) media must be at least this many raster lines long,
|
||||
// and no longer than this, or the printer rejects the job.
|
||||
const MIN_LENGTH_DOTS = 150;
|
||||
const MAX_LENGTH_DOTS = 11811;
|
||||
|
||||
/**
|
||||
* Media definitions. `printableDots` is how many of the 720 head dots
|
||||
* actually land on the label; `offsetR` is the gap between the right edge
|
||||
* of the printable area and the right edge of the head.
|
||||
*/
|
||||
const MEDIA = {
|
||||
// DK-22205 (62 mm white) and DK-22251 (62 mm black/red) — continuous.
|
||||
'62': {
|
||||
id: '62',
|
||||
label: '62 mm continuous',
|
||||
dieCut: false,
|
||||
widthMm: 62,
|
||||
lengthMm: 0,
|
||||
printableDots: 696,
|
||||
offsetR: 12,
|
||||
feedMargin: 35,
|
||||
},
|
||||
// Die-cut sizes, kept for completeness.
|
||||
'62x29': {
|
||||
id: '62x29',
|
||||
label: '62 mm x 29 mm die-cut',
|
||||
dieCut: true,
|
||||
widthMm: 62,
|
||||
lengthMm: 29,
|
||||
printableDots: 696,
|
||||
lengthDots: 271,
|
||||
offsetR: 12,
|
||||
feedMargin: 0,
|
||||
},
|
||||
'62x100': {
|
||||
id: '62x100',
|
||||
label: '62 mm x 100 mm die-cut',
|
||||
dieCut: true,
|
||||
widthMm: 62,
|
||||
lengthMm: 100,
|
||||
printableDots: 696,
|
||||
lengthDots: 1109,
|
||||
offsetR: 12,
|
||||
feedMargin: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function getMedia(id) {
|
||||
const media = MEDIA[id];
|
||||
if (!media) {
|
||||
throw new Error(
|
||||
`Unknown media "${id}". Known media: ${Object.keys(MEDIA).join(', ')}`
|
||||
);
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Bit packing
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Pack one row of the ink map into the 90 bytes the printer expects.
|
||||
*
|
||||
* The printer clocks each raster line out starting from the far side of the
|
||||
* head, so the first transmitted bit is the RIGHTMOST dot. brother_ql
|
||||
* achieves this by mirroring the image before packing; we do the same thing
|
||||
* directly with an index flip, which avoids materialising a mirrored copy.
|
||||
*
|
||||
* Get this wrong and every label prints mirrored. See the byte-order tests.
|
||||
*
|
||||
* @param {Uint8Array} ink Full-image ink map, 1 byte per dot, 1 = burn.
|
||||
* @param {number} rowStart Offset of this row within `ink`.
|
||||
* @returns {Buffer} 90 bytes.
|
||||
*/
|
||||
function packRow(ink, rowStart) {
|
||||
const out = Buffer.alloc(BYTES_PER_ROW);
|
||||
for (let x = 0; x < PIXEL_WIDTH; x++) {
|
||||
if (ink[rowStart + x] === 0) continue;
|
||||
const i = PIXEL_WIDTH - 1 - x;
|
||||
out[i >> 3] |= 0x80 >> (i & 7);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* TIFF PackBits encoder, as used by the QL compression mode.
|
||||
* Worst case for 90 bytes of input is 91 bytes out, so the result always
|
||||
* fits the single-byte length field.
|
||||
*/
|
||||
function packBits(input) {
|
||||
const out = [];
|
||||
let i = 0;
|
||||
const n = input.length;
|
||||
|
||||
while (i < n) {
|
||||
// Look for a run of 3+ identical bytes.
|
||||
let runEnd = i + 1;
|
||||
while (runEnd < n && input[runEnd] === input[i] && runEnd - i < 128) runEnd++;
|
||||
const runLength = runEnd - i;
|
||||
|
||||
if (runLength >= 3) {
|
||||
out.push(257 - runLength, input[i]);
|
||||
i = runEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise accumulate a literal run, stopping before any 3-byte run.
|
||||
let litStart = i;
|
||||
let litEnd = i;
|
||||
while (litEnd < n && litEnd - litStart < 128) {
|
||||
if (
|
||||
litEnd + 2 < n &&
|
||||
input[litEnd] === input[litEnd + 1] &&
|
||||
input[litEnd] === input[litEnd + 2]
|
||||
) {
|
||||
break;
|
||||
}
|
||||
litEnd++;
|
||||
}
|
||||
const litLength = litEnd - litStart;
|
||||
out.push(litLength - 1);
|
||||
for (let k = litStart; k < litEnd; k++) out.push(input[k]);
|
||||
i = litEnd;
|
||||
}
|
||||
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Command primitives
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const CMD = {
|
||||
invalidate: () => Buffer.alloc(INVALIDATE_BYTES, 0x00),
|
||||
initialize: () => Buffer.from([0x1b, 0x40]), // ESC @
|
||||
statusRequest: () => Buffer.from([0x1b, 0x69, 0x53]), // ESC i S
|
||||
switchToRaster: () => Buffer.from([0x1b, 0x69, 0x61, 0x01]), // ESC i a 1
|
||||
|
||||
/** ESC i z — media type, width, length, raster count, page flag. */
|
||||
mediaAndQuality({ media, rasterLines, firstPage, highQuality }) {
|
||||
let flags = 0x80; // "recover" bit, always set
|
||||
flags |= 1 << 1; // media type valid
|
||||
flags |= 1 << 2; // media width valid
|
||||
flags |= 1 << 3; // media length valid
|
||||
if (highQuality) flags |= 1 << 6;
|
||||
|
||||
const buf = Buffer.alloc(13);
|
||||
buf[0] = 0x1b;
|
||||
buf[1] = 0x69;
|
||||
buf[2] = 0x7a;
|
||||
buf[3] = flags;
|
||||
buf[4] = media.dieCut ? 0x0b : 0x0a;
|
||||
buf[5] = media.widthMm & 0xff;
|
||||
buf[6] = media.dieCut ? media.lengthMm & 0xff : 0x00;
|
||||
buf.writeUInt32LE(rasterLines >>> 0, 7);
|
||||
buf[11] = firstPage ? 0x00 : 0x01;
|
||||
buf[12] = 0x00;
|
||||
return buf;
|
||||
},
|
||||
|
||||
/** ESC i M — auto cut on/off (bit 6). */
|
||||
autoCut: (enabled) => Buffer.from([0x1b, 0x69, 0x4d, enabled ? 0x40 : 0x00]),
|
||||
|
||||
/** ESC i A — cut every n labels. */
|
||||
cutEvery: (n) => Buffer.from([0x1b, 0x69, 0x41, n & 0xff]),
|
||||
|
||||
/** ESC i K — bit 0 two-colour, bit 3 cut at end, bit 6 600 dpi. */
|
||||
expandedMode({ twoColour, cutAtEnd, dpi600 }) {
|
||||
let flags = 0x00;
|
||||
if (twoColour) flags |= 1 << 0;
|
||||
if (cutAtEnd) flags |= 1 << 3;
|
||||
if (dpi600) flags |= 1 << 6;
|
||||
return Buffer.from([0x1b, 0x69, 0x4b, flags]);
|
||||
},
|
||||
|
||||
/** ESC i d — feed / margin amount in dots. */
|
||||
margins(dots) {
|
||||
const buf = Buffer.alloc(5);
|
||||
buf[0] = 0x1b;
|
||||
buf[1] = 0x69;
|
||||
buf[2] = 0x64;
|
||||
buf.writeUInt16LE(dots & 0xffff, 3);
|
||||
return buf;
|
||||
},
|
||||
|
||||
/** M — compression mode (bit 1 = PackBits). */
|
||||
compression: (enabled) => Buffer.from([0x4d, enabled ? 0x02 : 0x00]),
|
||||
|
||||
/** 0x1A ends the last page, 0x0C ends an intermediate page. */
|
||||
print: (lastPage) => Buffer.from([lastPage ? 0x1a : 0x0c]),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Job assembly
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @typedef {Object} Plane
|
||||
* @property {number} width Must be 720.
|
||||
* @property {number} height Raster lines.
|
||||
* @property {Uint8Array} black 1 byte per dot, non-zero = burn black.
|
||||
* @property {Uint8Array} [red] Same shape; omit for monochrome.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the complete byte stream for one or more pages.
|
||||
*
|
||||
* @param {Plane[]} pages
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.media='62']
|
||||
* @param {boolean} [options.cut=true] Cut after the last label.
|
||||
* @param {number} [options.cutEvery=1]
|
||||
* @param {boolean} [options.highQuality=true]
|
||||
* @param {boolean} [options.compress=false]
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function buildJob(pages, options = {}) {
|
||||
const {
|
||||
media: mediaId = '62',
|
||||
cut = true,
|
||||
cutEvery = 1,
|
||||
highQuality = true,
|
||||
compress = false,
|
||||
} = options;
|
||||
|
||||
const media = getMedia(mediaId);
|
||||
if (!Array.isArray(pages) || pages.length === 0) {
|
||||
throw new Error('buildJob requires at least one page');
|
||||
}
|
||||
|
||||
const chunks = [
|
||||
CMD.switchToRaster(),
|
||||
CMD.invalidate(),
|
||||
CMD.initialize(),
|
||||
CMD.switchToRaster(),
|
||||
];
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
validatePage(page, media);
|
||||
|
||||
const twoColour = Boolean(page.red);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === pages.length - 1;
|
||||
|
||||
chunks.push(CMD.statusRequest());
|
||||
chunks.push(
|
||||
CMD.mediaAndQuality({
|
||||
media,
|
||||
rasterLines: page.height,
|
||||
firstPage: isFirst,
|
||||
highQuality,
|
||||
})
|
||||
);
|
||||
chunks.push(CMD.autoCut(cut));
|
||||
chunks.push(CMD.cutEvery(cutEvery));
|
||||
chunks.push(CMD.expandedMode({ twoColour, cutAtEnd: cut, dpi600: false }));
|
||||
chunks.push(CMD.margins(media.feedMargin));
|
||||
if (compress) chunks.push(CMD.compression(true));
|
||||
|
||||
chunks.push(encodeRasterData(page, compress));
|
||||
chunks.push(CMD.print(isLast));
|
||||
});
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function validatePage(page, media) {
|
||||
if (page.width !== PIXEL_WIDTH) {
|
||||
throw new Error(
|
||||
`Page width must be ${PIXEL_WIDTH} dots, got ${page.width}. ` +
|
||||
`Pad the ${media.printableDots}-dot printable area out to the full head width.`
|
||||
);
|
||||
}
|
||||
if (!media.dieCut) {
|
||||
if (page.height < MIN_LENGTH_DOTS) {
|
||||
throw new Error(
|
||||
`Page is ${page.height} dots long; continuous media needs at least ${MIN_LENGTH_DOTS}.`
|
||||
);
|
||||
}
|
||||
if (page.height > MAX_LENGTH_DOTS) {
|
||||
throw new Error(
|
||||
`Page is ${page.height} dots long; the maximum is ${MAX_LENGTH_DOTS}.`
|
||||
);
|
||||
}
|
||||
} else if (page.height !== media.lengthDots) {
|
||||
throw new Error(
|
||||
`Die-cut media ${media.id} needs exactly ${media.lengthDots} raster lines, got ${page.height}.`
|
||||
);
|
||||
}
|
||||
const expected = page.width * page.height;
|
||||
if (page.black.length !== expected) {
|
||||
throw new Error(
|
||||
`Black plane is ${page.black.length} bytes, expected ${expected}.`
|
||||
);
|
||||
}
|
||||
if (page.red && page.red.length !== expected) {
|
||||
throw new Error(`Red plane is ${page.red.length} bytes, expected ${expected}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRasterData(page, compress) {
|
||||
const chunks = [];
|
||||
const twoColour = Boolean(page.red);
|
||||
|
||||
for (let y = 0; y < page.height; y++) {
|
||||
const rowStart = y * page.width;
|
||||
const planes = twoColour
|
||||
? [packRow(page.black, rowStart), packRow(page.red, rowStart)]
|
||||
: [packRow(page.black, rowStart)];
|
||||
|
||||
planes.forEach((row, planeIndex) => {
|
||||
const payload = compress ? packBits(row) : row;
|
||||
// 'w' 0x01 = black plane, 'w' 0x02 = red plane, 'g' 0x00 = monochrome.
|
||||
const header = twoColour
|
||||
? Buffer.from([0x77, planeIndex === 0 ? 0x01 : 0x02, payload.length])
|
||||
: Buffer.from([0x67, 0x00, payload.length]);
|
||||
chunks.push(header, payload);
|
||||
});
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** Bytes that ask the printer for a status frame and nothing else. */
|
||||
function buildStatusRequest() {
|
||||
return Buffer.concat([CMD.invalidate(), CMD.initialize(), CMD.statusRequest()]);
|
||||
}
|
||||
|
||||
export {
|
||||
PIXEL_WIDTH,
|
||||
BYTES_PER_ROW,
|
||||
MIN_LENGTH_DOTS,
|
||||
MAX_LENGTH_DOTS,
|
||||
MEDIA,
|
||||
getMedia,
|
||||
buildJob,
|
||||
buildStatusRequest,
|
||||
packBits,
|
||||
packRow,
|
||||
CMD,
|
||||
};
|
||||
Reference in New Issue
Block a user