From 1b4b9d662af0c58e1736cea72744507340ac0795 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 7 Sep 2026 14:00:47 +1000 Subject: [PATCH] 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. --- src/printing/ql-raster.js | 359 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 src/printing/ql-raster.js diff --git a/src/printing/ql-raster.js b/src/printing/ql-raster.js new file mode 100644 index 0000000..9415dbb --- /dev/null +++ b/src/printing/ql-raster.js @@ -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, +};