NetKit — in bill tiếng Việt qua máy in nhiệt
Ứng dụng Android in hóa đơn tiếng Việt đủ dấu qua máy in nhiệt ESC/POS trên mạng, bằng cách vẽ chữ thành ảnh raster hoặc dùng code page tiếng Việt có sẵn trong máy.
- Flutter
- Dart
- Kotlin
- ESC/POS
- Android
Ứng dụng Android viết bằng Flutter để in hóa đơn tiếng Việt qua máy in nhiệt nối mạng, kèm mấy công cụ kiểm tra mạng dùng ngay tại chỗ.
Máy in nhiệt không biết chữ "Phở"#
Máy in bill ESC/POS nhận văn bản theo từng byte rồi tra vào code page nạp sẵn
trong firmware. Đa số máy chỉ có PC437 và PC850, hai code page DOS không có
lấy một ô cho ở, ữ hay ệ. Gửi UTF-8 thẳng xuống thì mỗi chữ có dấu nở
thành hai, ba byte rác: "Phở bò" ra giấy thành một chuỗi ký hiệu vô nghĩa và
lệch luôn canh lề, vì máy đếm thừa số ký tự trên dòng.
Tôi không sửa được firmware nên chỉ còn hai đường vòng: hoặc đừng bắt máy in đánh chữ nữa, hoặc tìm code page nào thật sự có tiếng Việt. NetKit làm cả hai và để người dùng chọn.
Vẽ chữ thành ảnh rồi gửi raster#
Cách chắc ăn là coi tờ bill như một tấm ảnh. Flutter dựng nguyên trang bằng
PictureRecorder và drawParagraph ở đúng bề ngang giấy tính theo dot — 384
dot cho khổ 58 mm, 576 dot cho khổ 80 mm — rồi đọc pixel ra RGBA. Chữ do Android
vẽ nên dấu nào cũng in được, và chọn được phông, độ đậm, cỡ chữ 80–180%, giãn
dòng, giãn chữ.
Việc còn lại là ép ảnh đó xuống 1 bit rồi đóng gói theo lệnh raster GS v 0:
Uint8List _encodeRaster(RenderedReceipt receipt, double inkThreshold) {
final cut = (inkThreshold * 255).round();
final widthBytes = (receipt.image.width + 7) ~/ 8;
/* Bộ đệm máy in rất nhỏ, nên cắt tờ bill thành từng lát 128 dòng. */
const sliceHeight = 128;
final output = BytesBuilder(copy: false);
final rgba = receipt.rgba;
for (var top = 0; top < receipt.image.height; top += sliceHeight) {
final height = (receipt.image.height - top).clamp(0, sliceHeight);
output.add([
0x1D, 0x76, 0x30, 0x00, // GS v 0: gửi một lát bitmap
widthBytes & 0xFF, (widthBytes >> 8) & 0xFF,
height & 0xFF, (height >> 8) & 0xFF,
]);
final packed = Uint8List(widthBytes * height);
for (var y = 0; y < height; y++) {
for (var x = 0; x < receipt.image.width; x++) {
final source = ((top + y) * receipt.image.width + x) * 4;
final luminance = (rgba[source] * 299 +
rgba[source + 1] * 587 +
rgba[source + 2] * 114) ~/ 1000;
if (rgba[source + 3] > 32 && luminance < cut) {
packed[y * widthBytes + (x >> 3)] |= 0x80 >> (x & 7);
}
}
}
output.add(packed);
}
return output.takeBytes();
}Payload đi qua Socket TCP thẳng tới port 9100, chia khúc 4 KB và flush sau
mỗi khúc; hết mỗi bản in thì đẩy thêm vài dòng trắng cho nội dung không dính
lưỡi cắt.
Nhờ máy in tự đánh chữ#
Chế độ ảnh luôn đúng nhưng in chậm hơn hẳn và nét chữ phụ thuộc vào rasterizer
của tôi. Bù lại, một số máy có sẵn VISCII (code page 69) hoặc Windows-1258
(code page 52), hai code page đủ chữ tiếng Việt. Với những máy đó, chế độ font
cứng gửi ESC t để chọn code page rồi mới gửi ký tự một byte:
static void _writeBlock(
BytesBuilder output,
String text,
ReceiptTemplate template, {
required int align,
required bool bold,
int width = 1,
int height = 1,
ReceiptNativeFont? font,
ReceiptCodePage? codePage,
}) {
final body = text.trim();
if (body.isEmpty) return;
final page = codePage ?? template.codePage;
output
..add([_esc, 0x74, page.id]) // ESC t: chọn bảng mã
..add([_esc, 0x61, align]) // ESC a: canh lề
..add([_esc, 0x4D, (font ?? template.nativeFont).id]) // ESC M: font A/B
..add([_esc, 0x45, bold ? 1 : 0]) // ESC E: in đậm
..add([_gs, 0x21, ((width - 1) << 4) | (height - 1)]) // GS !: cỡ chữ
..add(ReceiptCodePages.encode(body, page))
..add(const [0x0A]);
}Bảng tra rune sang byte tôi sinh sẵn từ RFC 1456 và bộ mapping của Microsoft. Windows-1258 đặc biệt ở chỗ phần lớn nguyên âm phải viết thành chữ gốc cộng một dấu thanh tổ hợp, nên một rune có thể ra hai byte.
Vì không có cách nào biết trước máy hỗ trợ code page nào, tôi thêm nút in thử kiểu chữ: in cùng một câu đủ dấu qua mọi code page và cả hai font cứng, chọn bằng mắt trên tờ giấy vừa in ra.
Những việc Flutter không với tới#
Phần Kotlin nhỏ nhưng cần thiết, nối qua một MethodChannel: đọc /proc/net/arp
để ghép MAC vào IP sau khi quét, giữ multicast lock trong lúc nghe Bonjour và
UPnP (không có lock thì Android không chuyển gói multicast cho ứng dụng), xin
preferredRefreshRate cao nhất mà màn hình hỗ trợ, đọc /proc/self/stat để
biết chính app tốn bao nhiêu CPU cho từng tác vụ, và dựng widget màn hình chính.
Template bill và mấy công cụ mạng#
Template bill lưu bằng shared_preferences dưới dạng JSON: đủ cho vài chục
template, chưa đáng để kéo SQLite vào. Store chỉ có upsert theo id, xoá, và
một bản nháp riêng để đóng app mở lại không mất nội dung đang gõ dở.
Phần mạng sinh ra từ cùng một tình huống — đứng tại quán và cần biết máy in đang
nằm ở IP nào. Quét LAN chạy song song ba hướng: ICMP qua binary ping của hệ
thống, dò TCP cho máy chặn ping, và hỏi thẳng Bonjour, UPnP, ONVIF. Kèm theo là
dò port, DNS lookup, TCP ping và Wake-on-LAN.
Kết quả#
- In được tiếng Việt đủ dấu trên cả máy có lẫn máy không có code page tiếng Việt
- Cùng một template bill ra giống nhau ở khổ 58 mm và 80 mm vì bố cục tính theo dot
- Tìm máy in và kiểm tra port 9100 ngay trong app, không cần mở laptop