Labels Printing Off-Center? Calibrate the Print Offset Once
A small label printer has no sensor to tell me how far off-center the paper sits. It prints exactly where I tell it to every time. The problem is that its coordinate system starts at the edge of the printhead, while the coordinate system in my head starts at the edge of the sticker. Those are not the same thing.
I had just finished connecting a Bluetooth label printer to a point-of-sale app I wrote myself, to the point where the printhead produced readable text. I thought the job was done, until I stuck a printed label onto an actual product bottle and saw that the beginning of the product name was cut off on the left, while almost a centimeter of blank space remained on the right.
I already covered the previous steps—finding out which commands the printer accepts and getting the printhead to fire—in Capturing a Bluetooth Label Printer Protocol with btsnoop. This article starts at the point where printing works, but does not line up with the paper, and everything here comes entirely from measurements on the hardware I have: printing actual labels and measuring them with a ruler, without touching the manufacturer's software.
Three Symptoms, One Step After Another
The first symptom was that the printer beeped twice every time I sent a print job, then fed out blank paper. At the time, I was pleased to hear it beep because that meant it had received the data. In fact, two beeps on this model mean rejection, not confirmation. It was telling me it could not read the bytes I sent.
The first lesson was simple: sounds from the printer tell me nothing about the printhead. I have to look at the paper.
The second symptom was that the paper advanced by exactly the number of labels requested, but not a single dot appeared. This meant the firmware had accepted the bytes but was not firing the printhead. The cause was an incomplete sequence of preceding commands. This model requires a job-start command and a left-margin command every time. Leave either one out, and it silently swallows the data.
object EscPosLabelOpcodes {
/** ESC @ - เคลียร์บัฟเฟอร์และรีเซ็ตโหมด */
val RESET: ByteArray = byteArrayOf(0x1B, 0x40)
/** GS v 0, m=0 - พิมพ์บิตแมปแบบ raster ความเข้มปกติ */
val PRINT_RASTER_M0: ByteArray = byteArrayOf(0x1D, 0x76, 0x30, 0x00)
/** คำสั่งเปิดงานฉลาก ขาดตัวนี้เฟิร์มแวร์รับไบต์แล้วไม่ยิงหัวพิมพ์ */
val BEGIN_LABEL_JOB: ByteArray = byteArrayOf(0x1F, 0x11, 0x0A)
/** ตั้งขอบซ้ายเป็นหน่วยไบต์ (1 ไบต์ = 8 จุด) */
fun setLeftMarginBytes(marginByte: Int): ByteArray =
byteArrayOf(0x1F, 0x11, 0x24, (marginByte and 0xFF).toByte())
fun feedLines(n: Int): ByteArray =
byteArrayOf(0x1B, 0x64, (n.coerceIn(0, 255) and 0xFF).toByte())
}
A common mistake involves 0x30 in the raster print command. It is the character '0', which is part of the opcode, not a parameter. The following 0x00 is the density parameter. Removing either one gives the same result: blank paper.
This is the actual sequence sent for each label, and the entire sequence is repeated for every label, rather than sending the header once and then streaming images.
repeat(quantity.coerceAtLeast(1)) {
out.write(EscPosLabelOpcodes.BEGIN_LABEL_JOB) // 1F 11 0A
out.write(EscPosLabelOpcodes.setLeftMarginBytes(0)) // 1F 11 24 00
out.write(EscPosLabelOpcodes.RESET) // 1B 40
out.write(EscPosLabelOpcodes.PRINT_RASTER_M0) // 1D 76 30 00
out.write(rasterBytes) // wL wH hL hH + ข้อมูลจุด
out.flush()
Thread.sleep(400)
}
Thread.sleep(800)
In the opcode set above, I have a feedLines function ready, but I never call it anywhere, because the firmware already uses its sensor to advance the paper to the next label boundary. Sending another feed command doubles the advance, wasting one sticker every time I print. This differs from continuous-paper receipt printers, which always need a feed command at the end of a job.
Once It Prints, the Problem Is Pure Geometry
Once the printhead fired, the rest had nothing to do with the protocol. It became a question of where I placed the image on the canvas. The raster encoder only accepts a bitmap exactly as wide as the printhead. It adds no padding.
val wBytes = headDots / 8 // 384 จุด -> 48 ไบต์ต่อแถว
val h = bmp.height
val out = ByteArray(4 + wBytes * h)
out[0] = (wBytes and 0xFF).toByte() // ความกว้างเป็น "ไบต์"
out[1] = ((wBytes ushr 8) and 0xFF).toByte()
out[2] = (h and 0xFF).toByte() // ความสูงเป็น "จุด"
out[3] = ((h ushr 8) and 0xFF).toByte()
require(headDots % 8 == 0)
// ถ้าบิตแมปกว้างไม่เท่าหัวพิมพ์ ให้ผู้เรียกไปเติมขอบมาก่อน
require(bmp.width == headDots) {
"bitmap ${bmp.width}x$h ไม่ตรงกับ head=$headDots"
}
Bits are packed with the most significant bit on the left, and 1 means fire a dot. Reversing the polarity here produces a completely black label, which is an immediately obvious mistake and does no harm.
for (xb in 0 until wBytes) {
var packed = 0
for (bit in 0..7) {
val c = rowPixels[xb * 8 + bit]
val dark = if (Color.alpha(c) < 16) false else luminance(c) < threshold
if (dark) packed = packed or (1 shl (7 - bit))
}
out[idx++] = packed.toByte()
}
The only constant I need to know is the resolution. Most printers in this group use 8 dots per millimeter (203 dpi), while some larger models use 12 dots per millimeter. I store this with the printer model, not the paper, because it is a property of the printhead.
/** ความละเอียดหัวพิมพ์ หน่วยเป็นจุดต่อมิลลิเมตร (8 = 203dpi, 12 = 300dpi) */
val dotsPerMm: Int = 8,
/** เลื่อนเนื้อหาไปขวา (+) หรือซ้าย (-) หน่วยมิลลิเมตร */
val adjustXMm: Double = 0.0,
/** เลื่อนเนื้อหาลง (+) หรือขึ้น (-) หน่วยมิลลิเมตร */
val adjustYMm: Double = 0.0,
Three Different Values That Got Collapsed into One
This is the core of the whole problem. When I say "38 x 25 paper," there are three values that need to be kept separate, and I mixed them up from the start.
First, printhead width: 384 dots at 8 dots per millimeter is 48 mm. This is the width of the canvas I can send, regardless of the paper loaded.
Second, actual paper size: 38 x 25 mm is the sticker I peel off and apply. It is 10 mm narrower than the printhead.
Third, actual printable area: roughly 30 x 20 mm is the part where printing stays within the label. The paper is indeed 38 wide, but the margins around the label and the cut lines take up 4 mm on each side. If I print across the full 38, the ink ends up on the backing paper and dirties the rollers.
My original code calculated the placement like this, which looks perfectly reasonable on reading it.
val headWidthMm = headDots.toDouble() / dpmm // 384 / 8 = 48.0
val centerXMm = (headWidthMm - paper.printableWidthMm) / 2.0
val centerYMm = (paper.heightMm - paper.printableHeightMm) / 2.0
val xPosDots = ((centerXMm + paper.adjustXMm) * dpmm).toInt()
val yPosDots = ((centerYMm + paper.adjustYMm) * dpmm).toInt()
val canvas = Bitmap.createBitmap(headDots, canvasH, Bitmap.Config.ARGB_8888)
c.drawColor(Color.WHITE)
c.drawBitmap(content, xPosDots.toFloat(), yPosDots.toFloat(), null)
Notice the asymmetry in the two middle lines. Horizontally, I centered relative to the printhead, while vertically, I centered relative to the label. I did this because I had assumed the paper would sit exactly in the middle of the printhead.
That is not how it works in practice. This model's paper guide pushes the paper to one side rather than centering it. My calculated placement therefore shifted the entire strip. Text on the left was cut off, while blank space remained on the right. This is why an offset is necessary.
Stop Guessing and Print a Ruler to Measure It
My first attempt was guesswork: I entered 5 mm and printed a label. Not enough. I tried 8, still not enough. At 12, it finally lined up. I wasted more than ten stickers and half an hour because each time I had to look at the result and guess that it was "probably still a little short."
No idea how far off it is
I have to print an actual label every time, judge the offset by eye, and estimate the next value by feel. If I change the paper size or the printer, I have to start guessing all over again.
Read the value straight off the label
The millimeter scale is already on the sticker. At a glance, I can see how many millimeters are missing on the left, enter that value directly, and finish in one or two rounds. It works with any paper size.
My test image generator draws four things on a canvas exactly the size of the printable area, not the paper size, because I want to know where the printable area I defined actually lands on the label.
- A border around the entire printable area. If any edge is missing from the printout, the image extends beyond that side of the label.
- Four corner markers, each with a different shape, sized 3 mm: a filled square, an outline square, a filled circle, and an outline circle. One label is enough to tell whether the image is upside down or mirrored.
- A crosshair at the center of the label, with each arm 2 mm long, to compare the actual center with the apparent center.
- Millimeter rulers along the top and left edges, with a small tick every 1 mm and a large numbered tick every 4 mm.
- The print time, because after more than ten test rounds, I can no longer tell which label in the pile on the desk came before which.
val mk = (3 * dpmm).toFloat()
c.drawRect(0f, 0f, mk, mk, fill) // มุมบนซ้าย สี่เหลี่ยมทึบ
c.drawRect(w - mk, 0f, w.toFloat(), mk, pen) // มุมบนขวา สี่เหลี่ยมโปร่ง
c.drawCircle(mk / 2, h - mk / 2, mk / 2, fill) // มุมล่างซ้าย วงกลมทึบ
c.drawCircle(w - mk / 2, h - mk / 2, mk / 2, pen) // มุมล่างขวา วงกลมโปร่ง
for (mm in 0..paper.printableWidthMm.toInt()) {
val x = (mm * dpmm).toFloat().coerceAtMost((w - 1).toFloat())
val long = mm % 4 == 0
c.drawLine(x, 0f, x, if (long) (2 * dpmm).toFloat() else (dpmm / 2f), pen)
if (long) c.drawText(mm.toString(), x + 1f, 3.2f * dpmm, numPaint)
}
The key to shortening the test cycle was that the test image had to print using the values currently being adjusted, before saving. The shop staff tap the arrows to adjust by half a millimeter, tap test print, inspect the label, and keep adjusting until satisfied, then save once.
// เอาค่าที่ยังไม่ได้บันทึกมาทับสเปกกระดาษ ให้ดวงทดสอบตรงกับที่กำลังปรับ
val tuned = paper.copy(adjustXMm = s.currentX, adjustYMm = s.currentY)
I limited adjustment to plus or minus 15 mm, in steps of 0.5 mm. Anything beyond that means the wrong paper size was selected in the first place, which is not something to fix with an offset. The buttons also need to be large enough for shop staff to tap with a thumb, not so small that they need to aim with a fingernail.
Store Offsets per Printer, Not per Model
I did not put the measured values into the code. I stored them in the app's settings, keyed by the pair of printer ID and paper type ID.
fun save(printerSn: String, paperId: String, calibration: Calibration) {
prefs.edit()
.putString(keyFor(printerSn, paperId),
"${calibration.adjustXMm}|${calibration.adjustYMm}")
.apply()
}
/** ไม่มีค่าที่บันทึกไว้ = ใช้ค่าเริ่มต้นของสเปกกระดาษนั้น */
fun resolved(printerSn: String, paper: PaperSpec): PaperSpec {
val o = loadOverride(printerSn, paper.id) ?: return paper
return paper.copy(adjustXMm = o.adjustXMm, adjustYMm = o.adjustYMm)
}
The key needs to use the printer ID rather than the model name because two printers of the same model produce different values. The paper guide is a mechanical assembly with its own tolerances. A shop with two printers that back each other up therefore needs to calibrate each one separately. Paper also needs to be distinguished, because different sticker batches have different backing-paper widths.
The actual printing path must always go through this value-merging function. It must never take values directly from the raw paper specifications, or the adjustments the shop staff made will have no effect at all. This is a very hard bug to find because everything looks correct.
Things the App Cannot Fix
These printers remember internally whether they are loaded with continuous paper or die-cut labels with gaps the sensor can detect. This setting is configured once when pairing the printer and remains stored in it. My print command sequence does not send this setting, which means that if someone sets a new printer to continuous-paper mode and then loads stickers, it will print across label boundaries, and nothing in the app can fix it. The setting has to be changed on the printer itself. I put this first in the shop manual because the symptom looks very much like an incorrect offset.
Another thing to allow for is the quiet zone around the barcode. I set the barcode's side margins to zero so the bars span the full width of the printable area, then use the 4 mm of unprinted paper margin on each side as the quiet zone instead. This gives me a much larger barcode that is easier to scan, but it only works when the offset is correct. If the print shifts to one side, the quiet zone on that side disappears and the scanner cannot read it.
val padL = 2.5f * dpmm // ซ้ายเผื่อมากกว่าขวานิดหน่อยตามที่หน้าร้านบอกมา
val padR = 2.0f * dpmm
val padT = 1.0f * dpmm
val padB = 0.6f * dpmm
val bcPadX = 0f // บาร์โค้ดกินเต็มความกว้าง ยืมขอบกระดาษเป็นระยะเงียบ
For height, I reserve about 8 mm for the barcode bars on a 20 mm-high label, because cheap scanner cameras cannot pick up shorter bars. I also disable all text antialiasing, because the encoder ultimately decides between black and white using a single threshold anyway. The gray pixels from antialiasing only make the text thicker in unpredictable ways.
The Sequence I Now Use for Every New Printer
For me, the lesson from this job was not about opcodes. It was about being willing to spend half an hour writing a ruler-printing tool instead of endlessly guessing half a millimeter at a time. The test printer took less time to write than I had already wasted on a single round of guessing, and I can keep using it with every additional printer the shop buys.
The sequence I use now is to first set the printer's paper mode to match the loaded stickers, then select the paper size in the app, print one ruler label, read the missing distance from the scale, enter that value, print another label to confirm, and save it against that printer. Done within two labels.
For anyone building an app that prints labels, I recommend separating the three values I discussed right from the data-structure design: paper size, printable area, and offset. Do not collapse them into one just because they look redundant, because when the shop changes sticker suppliers, only one of those three values needs to change. If I have left anything out or made a typo, I will come back and correct it later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first