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:
2026-09-07 14:00:47 +10:00
parent 28b52145eb
commit 1b4b9d662a
+359
View File
@@ -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,
};