NetKit, Vietnamese receipts on thermal printers
An Android app that prints fully accented Vietnamese receipts on networked ESC/POS thermal printers, either as raster images or through a resident Vietnamese code page.
- Flutter
- Dart
- Kotlin
- ESC/POS
- Android
An Android app written in Flutter that prints Vietnamese receipts on networked thermal printers, with a handful of on-site network tools alongside it.
A thermal printer cannot spell "Phở"#
An ESC/POS receipt printer takes text one byte at a time and looks each byte up
in a code page baked into its firmware. Most units only carry PC437 and
PC850, two DOS pages with no slot at all for ở, ữ or ệ. Send UTF-8 straight
down and every accented letter swells into two or three junk bytes: "Phở bò"
comes out as a run of meaningless symbols, and the alignment drifts too, because
the printer counts more characters on the line than there are.
I cannot patch the firmware, so only two ways around it were left: stop asking the printer to type at all, or find a code page that really does carry Vietnamese. NetKit does both and lets the user choose.
Draw the text, send it as a raster#
The safe route is to treat the receipt as a picture. Flutter lays the whole page
out with PictureRecorder and drawParagraph at the paper's exact dot width —
384 dots for 58 mm, 576 dots for 80 mm — then reads the pixels back as RGBA.
Android draws the glyphs, so every diacritic survives, and the typeface, weight,
type size of 80–180%, line height and letter spacing are all adjustable.
What is left is squeezing that image down to one bit and packing it into the
GS v 0 raster command:
Uint8List _encodeRaster(RenderedReceipt receipt, double inkThreshold) {
final cut = (inkThreshold * 255).round();
final widthBytes = (receipt.image.width + 7) ~/ 8;
/* Printer buffers are tiny, so the bill goes out in 128-row slices. */
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: one bitmap slice
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();
}The payload goes over a raw TCP Socket to port 9100 in 4 KB chunks with a
flush after each one, and every copy ends with a few blank lines so the content
never sits against the cutter.
Letting the printer type#
The image path is always correct, but it prints noticeably slower and the letter
shapes depend on my rasteriser. Some units, on the other hand, ship with VISCII
(page 69) or Windows-1258 (page 52), which do spell Vietnamese. For those, the
resident-font mode sends ESC t to select the page before sending single-byte
characters:
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: code page
..add([_esc, 0x61, align]) // ESC a: alignment
..add([_esc, 0x4D, (font ?? template.nativeFont).id]) // ESC M: font A/B
..add([_esc, 0x45, bold ? 1 : 0]) // ESC E: emphasis
..add([_gs, 0x21, ((width - 1) << 4) | (height - 1)]) // GS !: size
..add(ReceiptCodePages.encode(body, page))
..add(const [0x0A]);
}The rune-to-byte tables are generated ahead of time from RFC 1456 and Microsoft's mapping files. Windows-1258 is the odd one: most vowels have to be written as a base letter followed by a combining tone mark, so a single rune can produce two bytes.
Since there is no way to know in advance which pages a given unit supports, I added a type sample button: it prints the same fully accented line through every code page and both resident fonts, so the right combination gets picked by eye off the paper.
What Flutter could not reach#
The Kotlin side is small but necessary, wired up through one MethodChannel: it
reads /proc/net/arp to attach MAC addresses to the scanned IPs, holds the
multicast lock while Bonjour and UPnP announcements are being listened for
(without it Android never hands multicast packets to the app), requests the
highest preferredRefreshRate the display offers, reads /proc/self/stat to
report how much CPU the app itself spends per task, and drives the home screen
widget.
Templates and the network tools#
Receipt templates are stored as JSON in shared_preferences — fine for a few
dozen of them, not yet worth pulling SQLite in. The store only does upsert by
id, delete, and a separate draft so closing the app does not lose whatever was
half-typed.
The network side came out of the same situation: standing in a shop, needing to
know which IP the printer is on. The LAN sweep runs three ways at once — ICMP
through the system ping binary, TCP probes for hosts that drop pings, and
direct questions to Bonjour, UPnP and ONVIF — plus port scanning, DNS lookup,
TCP ping and Wake-on-LAN.
Outcome#
- Fully accented Vietnamese on printers with and without a Vietnamese code page
- One template prints identically at 58 mm and 80 mm, because the layout is in dots
- Finding the printer and checking port 9100 happens in the app, not on a laptop