diff --git a/src/printing/ql-raster.js b/src/printing/ql-raster.js index 9415dbb..2bb22bf 100644 --- a/src/printing/ql-raster.js +++ b/src/printing/ql-raster.js @@ -1,9 +1,10 @@ /** * 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. + * Byte-level command sequence follows Brother's "Raster Command Reference, + * QL-800/810W/820NWB, Version 1.01". Where that manual and the reference + * implementation in pklaus/brother_ql disagree, the manual wins — brother_ql + * targets older models and its defaults are not correct for this hardware. * * 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, @@ -13,17 +14,21 @@ 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; +// Manual section 2.1: "Sends a 400-byte invalidate command". brother_ql sends +// 200, which works on older models but is not what this hardware documents. +const INVALIDATE_BYTES = 400; + +// Manual section 2.3.4. Continuous media outside this range is rejected. +const MIN_LENGTH_DOTS = 150; // 12.7 mm +const MAX_LENGTH_DOTS = 11811; // 1000 mm /** - * 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. + * Media definitions, from manual sections 2.3.2 and 2.3.5. + * + * `printableDots` is how many of the 720 head pins land on the label; + * `offsetR` is the number of right-margin pins. For 62 mm continuous the + * manual gives 12 left / 696 print area / 12 right. */ const MEDIA = { // DK-22205 (62 mm white) and DK-22251 (62 mm black/red) — continuous. @@ -35,7 +40,7 @@ const MEDIA = { lengthMm: 0, printableDots: 696, offsetR: 12, - feedMargin: 35, + feedMargin: 35, // 3 mm, the documented minimum for continuous tape }, // Die-cut sizes, kept for completeness. '62x29': { @@ -47,7 +52,7 @@ const MEDIA = { printableDots: 696, lengthDots: 271, offsetR: 12, - feedMargin: 0, + feedMargin: 0, // must be 0 for die-cut }, '62x100': { id: '62x100', @@ -80,9 +85,9 @@ function getMedia(id) { * 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. + * 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. * @@ -154,13 +159,24 @@ const CMD = { statusRequest: () => Buffer.from([0x1b, 0x69, 0x53]), // ESC i S switchToRaster: () => Buffer.from([0x1b, 0x69, 0x61, 0x01]), // ESC i a 1 + /** ESC i ! — automatic status notification. 0 = notify, 1 = stay quiet. */ + statusNotification: (notify) => + Buffer.from([0x1b, 0x69, 0x21, notify ? 0x00 : 0x01]), + /** 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; + mediaAndQuality({ media, rasterLines, firstPage, highQuality, twoColour }) { + let flags = 0x80; // printer recovery always on + flags |= 0x02; // media type valid + flags |= 0x04; // media width valid + + // Only meaningful for die-cut stock. Brother's own driver sends 0x86 for + // continuous tape — no length bit — and the manual warns that asserting a + // valid flag the loaded media doesn't match returns a "replace media" + // error (error information 2, bit 0). + if (media.dieCut) flags |= 0x08; + + // The manual marks the print-quality bit as invalid for two-colour work. + if (highQuality && !twoColour) flags |= 0x40; const buf = Buffer.alloc(13); buf[0] = 0x1b; @@ -170,6 +186,8 @@ const CMD = { buf[4] = media.dieCut ? 0x0b : 0x0a; buf[5] = media.widthMm & 0xff; buf[6] = media.dieCut ? media.lengthMm & 0xff : 0x00; + // For two-colour, one raster line is the whole 186-byte packet + // ('w' 01 90 <90> 'w' 02 90 <90>), so this stays a line count either way. buf.writeUInt32LE(rasterLines >>> 0, 7); buf[11] = firstPage ? 0x00 : 0x01; buf[12] = 0x00; @@ -201,7 +219,7 @@ const CMD = { return buf; }, - /** M — compression mode (bit 1 = PackBits). */ + /** M — compression mode (2 = PackBits). Not supported on the QL-800. */ compression: (enabled) => Buffer.from([0x4d, enabled ? 0x02 : 0x00]), /** 0x1A ends the last page, 0x0C ends an intermediate page. */ @@ -246,12 +264,8 @@ function buildJob(pages, options = {}) { throw new Error('buildJob requires at least one page'); } - const chunks = [ - CMD.switchToRaster(), - CMD.invalidate(), - CMD.initialize(), - CMD.switchToRaster(), - ]; + // Initialisation commands, once per job (manual section 2.1). + const chunks = [CMD.invalidate(), CMD.initialize()]; pages.forEach((page, index) => { validatePage(page, media); @@ -260,13 +274,16 @@ function buildJob(pages, options = {}) { const isFirst = index === 0; const isLast = index === pages.length - 1; - chunks.push(CMD.statusRequest()); + // Control codes, repeated for every page, in the manual's documented order. + chunks.push(CMD.switchToRaster()); + chunks.push(CMD.statusNotification(true)); chunks.push( CMD.mediaAndQuality({ media, rasterLines: page.height, firstPage: isFirst, highQuality, + twoColour, }) ); chunks.push(CMD.autoCut(cut)); @@ -328,7 +345,8 @@ function encodeRasterData(page, compress) { planes.forEach((row, planeIndex) => { const payload = compress ? packBits(row) : row; - // 'w' 0x01 = black plane, 'w' 0x02 = red plane, 'g' 0x00 = monochrome. + // 'w' 0x01 = first colour (high energy, black), 'w' 0x02 = second colour + // (low energy, red). 'g' 0x00 is the monochrome transfer. const header = twoColour ? Buffer.from([0x77, planeIndex === 0 ? 0x01 : 0x02, payload.length]) : Buffer.from([0x67, 0x00, payload.length]); @@ -347,6 +365,7 @@ function buildStatusRequest() { export { PIXEL_WIDTH, BYTES_PER_ROW, + INVALIDATE_BYTES, MIN_LENGTH_DOTS, MAX_LENGTH_DOTS, MEDIA,