Correct the command stream against Brother's raster manual

Checked against "Raster Command Reference, QL-800/810W/820NWB v1.01". Four
things were wrong, all inherited from brother_ql's older-model defaults:

- Invalidate is 400 bytes for this series, not 200.
- The print-quality bit (0x40) is documented as invalid for two-colour
  printing; it was being set regardless.
- The media-length valid bit belongs to die-cut stock only. Brother's driver
  sends 0x86 for continuous tape, and the manual warns that asserting a valid
  flag the loaded media doesn't match returns a "replace media" error.
- ESC i ! (automatic status notification) was missing, and the control codes
  now follow the manual's per-page order.

Also confirms the 62 mm geometry we were already using: 12 left margin pins,
696 print area pins, 12 right.
This commit is contained in:
2026-09-07 14:25:19 +10:00
parent 851e021fc2
commit b4001c97c8
+50 -31
View File
@@ -1,9 +1,10 @@
/** /**
* Brother QL-8xx raster command builder. * Brother QL-8xx raster command builder.
* *
* Byte-level command sequence follows Brother's "Raster Command Reference" * Byte-level command sequence follows Brother's "Raster Command Reference,
* for the QL-800/810W/820NWB series, cross-checked against the reference * QL-800/810W/820NWB, Version 1.01". Where that manual and the reference
* implementation in pklaus/brother_ql. * 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 * 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, * raster line transmitted is therefore exactly 90 bytes before compression,
@@ -13,17 +14,21 @@
const PIXEL_WIDTH = 720; const PIXEL_WIDTH = 720;
const BYTES_PER_ROW = PIXEL_WIDTH / 8; // 90 const BYTES_PER_ROW = PIXEL_WIDTH / 8; // 90
const INVALIDATE_BYTES = 200;
// Endless (continuous) media must be at least this many raster lines long, // Manual section 2.1: "Sends a 400-byte invalidate command". brother_ql sends
// and no longer than this, or the printer rejects the job. // 200, which works on older models but is not what this hardware documents.
const MIN_LENGTH_DOTS = 150; const INVALIDATE_BYTES = 400;
const MAX_LENGTH_DOTS = 11811;
// 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 * Media definitions, from manual sections 2.3.2 and 2.3.5.
* actually land on the label; `offsetR` is the gap between the right edge *
* of the printable area and the right edge of the head. * `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 = { const MEDIA = {
// DK-22205 (62 mm white) and DK-22251 (62 mm black/red) — continuous. // DK-22205 (62 mm white) and DK-22251 (62 mm black/red) — continuous.
@@ -35,7 +40,7 @@ const MEDIA = {
lengthMm: 0, lengthMm: 0,
printableDots: 696, printableDots: 696,
offsetR: 12, offsetR: 12,
feedMargin: 35, feedMargin: 35, // 3 mm, the documented minimum for continuous tape
}, },
// Die-cut sizes, kept for completeness. // Die-cut sizes, kept for completeness.
'62x29': { '62x29': {
@@ -47,7 +52,7 @@ const MEDIA = {
printableDots: 696, printableDots: 696,
lengthDots: 271, lengthDots: 271,
offsetR: 12, offsetR: 12,
feedMargin: 0, feedMargin: 0, // must be 0 for die-cut
}, },
'62x100': { '62x100': {
id: '62x100', id: '62x100',
@@ -80,9 +85,9 @@ function getMedia(id) {
* Pack one row of the ink map into the 90 bytes the printer expects. * 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 * 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 * head, so the first transmitted bit is the RIGHTMOST dot. brother_ql achieves
* achieves this by mirroring the image before packing; we do the same thing * this by mirroring the image before packing; we do the same thing directly
* directly with an index flip, which avoids materialising a mirrored copy. * with an index flip, which avoids materialising a mirrored copy.
* *
* Get this wrong and every label prints mirrored. See the byte-order tests. * 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 statusRequest: () => Buffer.from([0x1b, 0x69, 0x53]), // ESC i S
switchToRaster: () => Buffer.from([0x1b, 0x69, 0x61, 0x01]), // ESC i a 1 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. */ /** ESC i z — media type, width, length, raster count, page flag. */
mediaAndQuality({ media, rasterLines, firstPage, highQuality }) { mediaAndQuality({ media, rasterLines, firstPage, highQuality, twoColour }) {
let flags = 0x80; // "recover" bit, always set let flags = 0x80; // printer recovery always on
flags |= 1 << 1; // media type valid flags |= 0x02; // media type valid
flags |= 1 << 2; // media width valid flags |= 0x04; // media width valid
flags |= 1 << 3; // media length valid
if (highQuality) flags |= 1 << 6; // 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); const buf = Buffer.alloc(13);
buf[0] = 0x1b; buf[0] = 0x1b;
@@ -170,6 +186,8 @@ const CMD = {
buf[4] = media.dieCut ? 0x0b : 0x0a; buf[4] = media.dieCut ? 0x0b : 0x0a;
buf[5] = media.widthMm & 0xff; buf[5] = media.widthMm & 0xff;
buf[6] = media.dieCut ? media.lengthMm & 0xff : 0x00; 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.writeUInt32LE(rasterLines >>> 0, 7);
buf[11] = firstPage ? 0x00 : 0x01; buf[11] = firstPage ? 0x00 : 0x01;
buf[12] = 0x00; buf[12] = 0x00;
@@ -201,7 +219,7 @@ const CMD = {
return buf; 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]), compression: (enabled) => Buffer.from([0x4d, enabled ? 0x02 : 0x00]),
/** 0x1A ends the last page, 0x0C ends an intermediate page. */ /** 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'); throw new Error('buildJob requires at least one page');
} }
const chunks = [ // Initialisation commands, once per job (manual section 2.1).
CMD.switchToRaster(), const chunks = [CMD.invalidate(), CMD.initialize()];
CMD.invalidate(),
CMD.initialize(),
CMD.switchToRaster(),
];
pages.forEach((page, index) => { pages.forEach((page, index) => {
validatePage(page, media); validatePage(page, media);
@@ -260,13 +274,16 @@ function buildJob(pages, options = {}) {
const isFirst = index === 0; const isFirst = index === 0;
const isLast = index === pages.length - 1; 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( chunks.push(
CMD.mediaAndQuality({ CMD.mediaAndQuality({
media, media,
rasterLines: page.height, rasterLines: page.height,
firstPage: isFirst, firstPage: isFirst,
highQuality, highQuality,
twoColour,
}) })
); );
chunks.push(CMD.autoCut(cut)); chunks.push(CMD.autoCut(cut));
@@ -328,7 +345,8 @@ function encodeRasterData(page, compress) {
planes.forEach((row, planeIndex) => { planes.forEach((row, planeIndex) => {
const payload = compress ? packBits(row) : row; 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 const header = twoColour
? Buffer.from([0x77, planeIndex === 0 ? 0x01 : 0x02, payload.length]) ? Buffer.from([0x77, planeIndex === 0 ? 0x01 : 0x02, payload.length])
: Buffer.from([0x67, 0x00, payload.length]); : Buffer.from([0x67, 0x00, payload.length]);
@@ -347,6 +365,7 @@ function buildStatusRequest() {
export { export {
PIXEL_WIDTH, PIXEL_WIDTH,
BYTES_PER_ROW, BYTES_PER_ROW,
INVALIDATE_BYTES,
MIN_LENGTH_DOTS, MIN_LENGTH_DOTS,
MAX_LENGTH_DOTS, MAX_LENGTH_DOTS,
MEDIA, MEDIA,