ฉลากพิมพ์ไม่ตรงกระดาษ ตั้งระยะเริ่มพิมพ์ให้จบทีเดียว
เครื่องพิมพ์ฉลากตัวเล็กไม่มีเซนเซอร์บอกเราว่ากระดาษวางเยื้องไปเท่าไหร่ มันพิมพ์ตรงที่เราบอกให้พิมพ์ทุกครั้ง ปัญหาคือระบบพิกัดที่มันใช้คือขอบหัวพิมพ์ ส่วนระบบพิกัดที่เราคิดอยู่ในหัวคือขอบดวงสติกเกอร์ สองอันนี้ไม่ใช่อันเดียวกัน
พอดีผมต่อเครื่องพิมพ์ฉลากบลูทูธเข้ากับแอปขายหน้าร้านที่เขียนเองเสร็จแล้ว ถึงขั้นที่หัวพิมพ์ยิงออกมาเป็นตัวหนังสืออ่านออกได้ ตอนนั้นคิดว่างานจบแล้ว จนเอาสติกเกอร์ที่พิมพ์เสร็จไปแปะขวดสินค้าจริง แล้วเห็นว่าคำนำหน้าชื่อสินค้าโดนกินหายไปทางซ้าย ส่วนทางขวาเหลือที่ว่างเปล่าอยู่เกือบเซนติเมตร
ขั้นตอนก่อนหน้านี้ คือการหาว่าเครื่องพิมพ์รับคำสั่งแบบไหนและทำยังไงให้หัวพิมพ์ยิงออกมาได้ ผมเขียนไว้แล้วที่ ดักโปรโตคอลเครื่องพิมพ์ฉลากบลูทูธจาก btsnoop เล่มนี้เริ่มจากจุดที่พิมพ์ออกมาได้แล้ว แต่ไม่ตรงกระดาษ และทุกอย่างในนี้เป็นการวัดจากฮาร์ดแวร์ที่ผมมีอยู่ในมือล้วนๆ คือพิมพ์ออกมาจริงแล้วเอาไม้บรรทัดวัด ไม่ได้ไปยุ่งกับซอฟต์แวร์ของผู้ผลิต
สามอาการที่ต่อกันเป็นขั้นบันได
อาการแรกคือเครื่องร้องปิ๊บสองครั้งทุกครั้งที่สั่งพิมพ์ แล้วกระดาษออกมาเปล่า ตอนนั้นผมดีใจที่มันร้อง เพราะแปลว่ามันได้รับข้อมูล ที่จริงแล้วเสียงร้องสองครั้งของเครื่องรุ่นนี้คือเสียงปฏิเสธ ไม่ใช่เสียงยืนยัน มันกำลังบอกว่าไบต์ที่ส่งมาอ่านไม่ออก
บทเรียนข้อแรกเลยเป็นเรื่องง่ายๆ ว่าเสียงจากเครื่องพิมพ์ไม่ได้บอกอะไรเกี่ยวกับหัวพิมพ์เลย ต้องดูที่กระดาษอย่างเดียว
อาการที่สองคือกระดาษเลื่อนออกมาตามจำนวนดวงที่สั่งพอดี แต่ไม่มีหมึกติดสักจุด อันนี้คือเฟิร์มแวร์รับไบต์ไว้แล้วแต่ไม่ยิงหัวพิมพ์ สาเหตุอยู่ที่ลำดับคำสั่งนำหน้าไม่ครบ เครื่องรุ่นนี้ต้องการคำสั่งเปิดงานกับคำสั่งตั้งขอบซ้ายก่อนทุกครั้ง ขาดตัวใดตัวหนึ่งมันกลืนข้อมูลเงียบๆ
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())
}
จุดที่คนพลาดกันบ่อยคือ 0x30 ในคำสั่งพิมพ์ raster ครับ มันคือตัวอักษร '0' ที่เป็นส่วนหนึ่งของโอปโค้ด ไม่ใช่พารามิเตอร์ ส่วน 0x00 ตัวหลังต่างหากที่เป็นพารามิเตอร์ความเข้ม ตัดตัวไหนออกก็ได้ผลเดียวกันคือกระดาษเปล่า
ลำดับที่ส่งจริงต่อฉลากหนึ่งดวงเป็นแบบนี้ และส่งซ้ำทั้งชุดทุกดวง ไม่ใช่ส่งหัวครั้งเดียวแล้วยิงรูปรัว
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)
ในชุดโอปโค้ดข้างบนผมมีฟังก์ชัน feedLines เตรียมไว้ แต่ไม่ได้เรียกใช้เลยสักที่ เพราะเฟิร์มแวร์เลื่อนกระดาษไปหารอยต่อดวงถัดไปให้เองอยู่แล้วด้วยเซนเซอร์ของมัน ถ้าเราสั่งเลื่อนซ้ำเข้าไปอีก มันจะเลื่อนสองเท่า กินสติกเกอร์ทิ้งดวงหนึ่งทุกครั้งที่พิมพ์ ตรงนี้ต่างจากเครื่องพิมพ์ใบเสร็จกระดาษต่อเนื่องที่ต้องสั่งเลื่อนท้ายงานเสมอ
พอพิมพ์ออกแล้ว ปัญหาเปลี่ยนเป็นเรื่องเรขาคณิตล้วนๆ
พอหัวพิมพ์ยิงได้ ที่เหลือไม่เกี่ยวกับโปรโตคอลอีกแล้วครับ มันกลายเป็นเรื่องว่าเราวางรูปลงบนผืนผ้าใบตรงไหน ตัวเข้ารหัส raster รับบิตแมปที่กว้างเท่ากับความกว้างหัวพิมพ์เป๊ะๆ เท่านั้น ไม่มีการเติมขอบให้
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"
}
การแพ็กบิตเป็นแบบบิตสูงอยู่ซ้าย และ 1 คือยิงจุด ตรงนี้ถ้ากลับด้านจะได้ฉลากดำทั้งดวง ซึ่งเป็นความผิดพลาดที่เห็นปุ๊บรู้ปั๊บ ไม่อันตราย
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()
}
ค่าคงที่ที่ต้องรู้ตัวเดียวคือความละเอียด เครื่องกลุ่มนี้ส่วนใหญ่เป็น 8 จุดต่อมิลลิเมตร (203 dpi) บางรุ่นใหญ่เป็น 12 จุดต่อมิลลิเมตร ผมเก็บไว้ที่รุ่นเครื่อง ไม่ได้เก็บไว้ที่กระดาษ เพราะมันเป็นคุณสมบัติของหัวพิมพ์
/** ความละเอียดหัวพิมพ์ หน่วยเป็นจุดต่อมิลลิเมตร (8 = 203dpi, 12 = 300dpi) */
val dotsPerMm: Int = 8,
/** เลื่อนเนื้อหาไปขวา (+) หรือซ้าย (-) หน่วยมิลลิเมตร */
val adjustXMm: Double = 0.0,
/** เลื่อนเนื้อหาลง (+) หรือขึ้น (-) หน่วยมิลลิเมตร */
val adjustYMm: Double = 0.0,
สามค่าที่คนละเรื่องกัน แต่ถูกจับมารวมเป็นค่าเดียว
นี่คือใจกลางของปัญหาทั้งหมดครับ เวลาเราพูดว่า "กระดาษ 38 x 25" มันมีค่าที่ต้องแยกจากกันอยู่สามค่า แล้วผมเอามาปนกันตั้งแต่แรก
หนึ่ง ความกว้างหัวพิมพ์ 384 จุด ที่ 8 จุดต่อมิลลิเมตร คือ 48 มม. นี่คือความกว้างของผืนผ้าใบที่เราส่งไปได้ ไม่เกี่ยวกับกระดาษที่ใส่อยู่เลย
สอง ขนาดกระดาษจริง 38 x 25 มม. คือดวงสติกเกอร์ที่ลอกออกมาแปะ แคบกว่าหัวพิมพ์อยู่ 10 มม.
สาม พื้นที่พิมพ์จริง ประมาณ 30 x 20 มม. คือส่วนที่หมึกติดแล้วไม่ล้นออกนอกดวง กระดาษ 38 กว้างก็จริง แต่ขอบรอบดวงกับรอยตัดกินไปข้างละ 4 มม. ถ้าเราพิมพ์เต็ม 38 หมึกจะไปติดบนกระดาษรองกาวแล้วเลอะลูกกลิ้ง
โค้ดเดิมของผมคำนวณจุดวางแบบนี้ ซึ่งอ่านแล้วดูสมเหตุสมผลมาก
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)
สังเกตความไม่สมมาตรตรงสองบรรทัดกลางนะครับ แนวนอนผมจัดกลางเทียบกับหัวพิมพ์ ส่วนแนวตั้งจัดกลางเทียบกับดวงกระดาษ ที่เป็นแบบนี้เพราะผมตั้งสมมติฐานไว้เองว่ากระดาษจะถูกวางกึ่งกลางหัวพิมพ์พอดี
ของจริงไม่ใช่แบบนั้นครับ กลไกนำกระดาษของเครื่องรุ่นนี้ดันกระดาษชิดไปด้านหนึ่ง ไม่ได้อยู่กึ่งกลาง ค่าที่ผมคำนวณได้เลยเลื่อนไปทั้งแถบ ตัวหนังสือด้านซ้ายโดนกินหาย ส่วนขวาเหลือที่ว่าง นี่คือที่มาของค่าชดเชยที่ต้องมี
เลิกเดา แล้วพิมพ์ไม้บรรทัดออกมาวัด
รอบแรกผมแก้แบบเดา คือใส่ 5 มม. แล้วพิมพ์ดู ยังไม่พอ ใส่ 8 ยังไม่พอ ใส่ 12 ถึงตรง เสียสติกเกอร์ไปสิบกว่าดวงกับเวลาอีกครึ่งชั่วโมง เพราะทุกรอบผมต้องเดาจากการมองว่า "น่าจะขาดไปอีกนิดนึง"
ไม่รู้ว่าขาดไปเท่าไหร่
ต้องพิมพ์ฉลากจริงทุกรอบ แล้วเทียบด้วยสายตาว่าเยื้องไปมากน้อยแค่ไหน ประมาณค่าถัดไปจากความรู้สึก ถ้าเปลี่ยนขนาดกระดาษหรือเปลี่ยนเครื่อง ก็ต้องเริ่มเดาใหม่หมด
อ่านค่าจากดวงที่พิมพ์ได้เลย
ขีดสเกลมิลลิเมตรอยู่บนดวงสติกเกอร์แล้ว มองปุ๊บรู้ว่าขอบซ้ายหายไปกี่มิลลิเมตร ใส่ค่านั้นลงไปตรงๆ จบในหนึ่งหรือสองรอบ และใช้ได้กับกระดาษทุกขนาด
ตัวสร้างภาพทดสอบผมวาดสี่อย่างลงบนผืนผ้าใบที่มีขนาดเท่ากับพื้นที่พิมพ์ พอดี ไม่ใช่ขนาดกระดาษ เพราะเราต้องการรู้ว่าพื้นที่พิมพ์ที่เราประกาศไว้มันไปตกอยู่ตรงไหนของดวงจริง
- กรอบรอบพื้นที่พิมพ์ทั้งผืน ถ้าพิมพ์ออกมาแล้วขอบด้านไหนหายไป แปลว่าล้นออกนอกดวงด้านนั้น
- หมุดสี่มุมที่หน้าตาไม่เหมือนกันสักมุม ขนาด 3 มม. คือสี่เหลี่ยมทึบ สี่เหลี่ยมโปร่ง วงกลมทึบ วงกลมโปร่ง ทำให้ดูดวงเดียวก็รู้ว่าภาพกลับหัวหรือกลับด้านหรือเปล่า
- กากบาทกลางดวง แขนยาวข้างละ 2 มม. ไว้เทียบจุดกึ่งกลางจริงกับกึ่งกลางที่ตาเห็น
- ไม้บรรทัดมิลลิเมตรตามขอบบนและขอบซ้าย ขีดเล็กทุก 1 มม. ขีดใหญ่พร้อมตัวเลขทุก 4 มม.
- เวลาที่พิมพ์กำกับไว้ เพราะพอทดสอบไปสิบกว่ารอบ ดวงที่กองอยู่บนโต๊ะจะเรียงไม่ถูกว่าอันไหนมาก่อนมาหลัง
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)
}
จุดสำคัญที่ทำให้วงจรทดสอบสั้นลงมากคือ ภาพทดสอบต้องพิมพ์ด้วยค่าที่กำลังปรับอยู่ตอนนั้น ยังไม่ได้กดบันทึก คนหน้าร้านกดลูกศรปรับทีละครึ่งมิลลิเมตร กดพิมพ์ทดสอบ ดูดวงที่ออกมา กดปรับต่อ จนพอใจแล้วค่อยกดบันทึกทีเดียว
// เอาค่าที่ยังไม่ได้บันทึกมาทับสเปกกระดาษ ให้ดวงทดสอบตรงกับที่กำลังปรับ
val tuned = paper.copy(adjustXMm = s.currentX, adjustYMm = s.currentY)
ผมจำกัดช่วงปรับไว้ที่บวกลบ 15 มม. ทีละ 0.5 มม. เกินกว่านั้นแปลว่าเลือกขนาดกระดาษผิดตั้งแต่ต้น ไม่ใช่เรื่องที่จะมาแก้ด้วยค่าชดเชย และปุ่มต้องใหญ่พอให้คนขายของกดด้วยนิ้วโป้งได้ ไม่ใช่ปุ่มขนาดที่ต้องเล็งด้วยปลายเล็บ
เก็บค่าชดเชยเป็นรายเครื่อง ไม่ใช่รายรุ่น
ค่าที่ได้จากการวัดผมไม่ได้เอาไปแก้ในโค้ด แต่เก็บลงที่เก็บค่าตั้งของแอป โดยคีย์ด้วยคู่ของหมายเลขเครื่อง กับ รหัสชนิดกระดาษ
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)
}
ที่ต้องคีย์ด้วยหมายเลขเครื่อง ไม่ใช่ชื่อรุ่น เพราะเครื่องรุ่นเดียวกันสองตัวให้ค่าไม่เท่ากันครับ กลไกนำกระดาษเป็นงานประกอบ มีค่าคลาดเคลื่อนของมันอยู่ ร้านที่มีเครื่องพิมพ์สองตัวสำรองกันเลยต้องคาลิเบรตทีละตัว ส่วนกระดาษก็ต้องแยกด้วย เพราะสติกเกอร์คนละล็อตกันความกว้างกระดาษรองไม่เท่ากัน
และในทางเดินของการสั่งพิมพ์จริง ต้องเรียกผ่านตัวรวมค่าตัวนี้เสมอ ห้ามไปหยิบค่าจากสเปกกระดาษดิบๆ ใช้เอง ไม่งั้นค่าที่คนหน้าร้านนั่งปรับไว้จะไม่มีผลอะไรเลย ซึ่งเป็นบั๊กที่หายากมากเพราะทุกอย่างดูถูกไปหมด
เรื่องที่แก้จากฝั่งแอปไม่ได้
เครื่องกลุ่มนี้จำไว้ในตัวมันเองว่าตอนนี้ใส่กระดาษต่อเนื่องหรือสติกเกอร์ตัดเป็นดวงที่มีช่องว่างให้เซนเซอร์อ่าน ค่านี้ตั้งครั้งเดียวตอนจับคู่เครื่องแล้วค้างอยู่ในเครื่อง ชุดคำสั่งพิมพ์ของเราไม่ได้ส่งค่านี้ไปด้วย แปลว่าถ้าใครเอาเครื่องใหม่มาตั้งเป็นโหมดกระดาษต่อเนื่องแล้วใส่สติกเกอร์เข้าไป มันจะพิมพ์คร่อมรอยต่อดวง และไม่มีอะไรในแอปแก้ได้เลย ต้องไปตั้งที่ตัวเครื่อง ตรงนี้ผมเขียนไว้ในคู่มือหน้าร้านเป็นข้อแรก เพราะอาการมันเหมือนกับค่าชดเชยผิดมาก
อีกเรื่องที่ต้องเผื่อไว้คือระยะเงียบรอบบาร์โค้ด ผมตั้งขอบข้างของบาร์โค้ดเป็นศูนย์ ให้แท่งกินเต็มความกว้างพื้นที่พิมพ์ แล้วยืมขอบกระดาษที่พิมพ์ไม่ถึงข้างละ 4 มม. เป็นระยะเงียบแทน วิธีนี้ได้บาร์โค้ดใหญ่ที่สแกนง่ายกว่าเดิมมาก แต่มันใช้ได้ก็ต่อเมื่อค่าชดเชยถูกต้องเท่านั้น ถ้าเยื้องไปข้างเดียว ระยะเงียบด้านนั้นจะหายไปแล้วเครื่องสแกนอ่านไม่ออก
val padL = 2.5f * dpmm // ซ้ายเผื่อมากกว่าขวานิดหน่อยตามที่หน้าร้านบอกมา
val padR = 2.0f * dpmm
val padT = 1.0f * dpmm
val padB = 0.6f * dpmm
val bcPadX = 0f // บาร์โค้ดกินเต็มความกว้าง ยืมขอบกระดาษเป็นระยะเงียบ
ส่วนความสูง ผมกันไว้ให้แท่งบาร์โค้ดประมาณ 8 มม. บนฉลากสูง 20 มม. เพราะแท่งเตี้ยกว่านั้นกล้องของเครื่องสแกนราคาถูกจับไม่ติด และปิดการลบรอยหยักของตัวอักษรทั้งหมด เพราะสุดท้ายตัวเข้ารหัสตัดสินขาวดำที่ค่าเดียวอยู่แล้ว พิกเซลสีเทาที่ได้มาจากการลบรอยหยักมีแต่ทำให้ตัวอักษรอ้วนขึ้นแบบเดาไม่ได้
สรุปเป็นลำดับที่ผมใช้กับเครื่องใหม่ทุกตัว
ส่วนตัวผมคิดว่าบทเรียนของงานนี้ไม่ใช่เรื่องโอปโค้ดครับ มันคือการยอมเสียเวลาครึ่งชั่วโมงเขียนตัวพิมพ์ไม้บรรทัด แทนที่จะเดาไปเรื่อยๆ ทีละครึ่งมิลลิเมตร ตัวพิมพ์ทดสอบใช้เวลาเขียนน้อยกว่าเวลาที่ผมเสียไปกับการเดารอบเดียวด้วยซ้ำ แล้วมันใช้ได้ตลอดไปกับเครื่องทุกตัวที่ร้านซื้อมาเพิ่ม
ลำดับที่ผมใช้ตอนนี้คือ ตั้งโหมดกระดาษที่ตัวเครื่องให้ตรงกับสติกเกอร์ที่ใส่ก่อน แล้วเลือกขนาดกระดาษในแอป แล้วพิมพ์ไม้บรรทัดหนึ่งดวง อ่านค่าที่ขาดจากขีดสเกล ใส่ค่านั้นลงไป พิมพ์ยืนยันอีกดวง แล้วกดบันทึกผูกกับเครื่องตัวนั้น จบภายในสองดวง
ใครกำลังทำแอปที่ต้องพิมพ์ฉลาก แนะนำให้แยกสามค่าที่ผมพูดถึงออกจากกันตั้งแต่ออกแบบโครงข้อมูลเลยครับ ขนาดกระดาษ พื้นที่พิมพ์ และค่าชดเชย อย่ายุบเหลือค่าเดียวเพราะมันดูซ้ำซ้อน เพราะวันที่ร้านเปลี่ยนซัพพลายเออร์สติกเกอร์ ค่าที่ต้องแก้มีแค่ค่าเดียวในสามค่านั้น ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย