Struk ESC/POS 58mm di Flutter dengan esc_pos_utils

Sekitar 32 kolom di Font A dan kira-kira 42 di Font B, karena gulungan 58 mm memberi sekitar 48 mm area cetak. Gulungan 80 mm memberi 72 mm, yaitu 48 dan 64 kolom. Angka-angka itu adalah ukuran kanvas Anda, dan setiap keputusan tata letak adalah aritmetika di dalamnya.
Lebar baris dinyatakan dalam per-dua-belas, jadi lebar kolom dalam satu baris harus berjumlah 12. Baris nama-jumlah-nominal biasanya berupa pembagian 7-1-4. Generator mengubah proporsi itu menjadi jumlah kolom sesungguhnya yang tersirat dari ukuran kertas, dan itulah yang membuat satu builder bisa melayani printer 58 mm dan 80 mm.
Kirim angka lewat jaringan dan format di perangkat, dengan format yang dikunci alih-alih formatter yang sadar-locale. Kalau tidak, perangkat berbahasa Inggris akan mengelompokkan digit berbeda dari perangkat berbahasa Indonesia sehingga mencetak dua total yang tampak berbeda untuk transaksi yang sama, dan string terformat dari server sering membawa karakter yang tidak bisa dirender code page printer.
Keduanya mungkin, dan perintah native jauh lebih baik. esc_pos_utils_plus menyediakan helper qrcode dan barcode yang memancarkan perintah ESC/POS native, yang mencetak cepat dan tajam. Kode yang sama dikirim sebagai bitmap raster menjadi lambat, menguras printer baterai, dan bergantung pada dithering Anda.
Jadikan builder sebagai fungsi murni dari sebuah penjualan ke daftar byte, lalu commit daftar byte yang diharapkan sebagai golden fixture. Setiap perubahan tata letak atau encoding yang tidak disengaja menggagalkan tes alih-alih mengejutkan sebuah toko. Tambahkan pemeriksaan bergaya properti bahwa nama produk terpanjang yang masuk akal tetap menyisakan nominal utuh di dalam anggaran kolom.

Ringkasan Utama
Pada kertas 58 mm Anda mendapat sekitar 32 kolom Font A, dan esc_pos_utils_plus menyatakan tata letak sebagai baris berisi PosColumn dengan lebar yang totalnya 12. Membangun struk lewat kelas yang mengembalikan byte — dan tidak pernah menyentuh printer — membuat seluruh tata letak bisa diuji unit dengan golden byte fixture.
Bagian sulit dari pencetakan struk di Flutter bukan Bluetooth-nya. Yang sulit adalah struk merupakan masalah tata letak yang diselesaikan dengan aritmetika, di atas kanvas selebar sepertiga printer meja, dalam bahasa kolom alih-alih piksel. Salah aritmetika, dan setiap struk di toko itu miring secara halus.
Artikel ini membahas separuh pekerjaan yang berurusan dengan konten: bagaimana menyusun receipt builder di Dart agar mudah dibaca, dipakai ulang lintas ukuran kertas, dan bisa diuji tanpa perangkat keras terpasang.
Semuanya bermula dari lebar kertas. Gulungan 58 mm memberi sekitar 48 mm area cetak, yang pada sel karakter biasa menghasilkan sekitar 32 kolom di Font A dan sekitar 42 di Font B. Gulungan 80 mm memberi 72 mm, 48 kolom, dan 64 kolom. Angka-angka itu bukan saran gaya; itu ukuran kanvas Anda.
Generator meminta ukuran kertas alih-alih jumlah kolom lalu menurunkan sisanya, dan itu nyaman sampai ada yang memasukkan nilai keliru. Struk yang dibangun untuk 80 mm lalu dicetak di 58 mm tidak terpotong dengan cara yang kentara — ia diam-diam menjadi tidak sejajar, dengan nominal yang tidak lagi mendarat di margin kanan.
58 mm roll -> 48 mm print width -> Font A = 32 columns
Font B = 42 columns
80 mm roll -> 72 mm print width -> Font A = 48 columns
Font B = 64 columns
// esc_pos_utils_plus asks for the paper size, not the column count,
// and derives the rest — which is why passing the wrong PaperSize
// produces a receipt that is subtly, consistently misaligned.
final generator = Generator(PaperSize.mm58, profile);
Refactor paling berguna dalam sebuah POS Flutter adalah memisahkan hal yang membangun struk dari hal yang mengirimkannya. Begitu builder-nya menjadi fungsi murni dari sebuah penjualan ke daftar bilangan bulat, Anda bisa mencetak ke berkas, membandingkannya dengan fixture, dan menjalankan seluruh tata letak di CI.
/// A receipt builder that returns bytes and touches no hardware.
/// Everything about this class is unit-testable on a laptop.
class ReceiptBuilder {
ReceiptBuilder(this._generator);
final Generator _generator;
List<int> build(Sale sale) => [
..._header(sale),
..._lines(sale),
..._totals(sale),
..._footer(sale),
..._generator.feed(3),
..._generator.cut(),
];
List<int> _lines(Sale sale) => sale.items
.expand((item) => [
..._generator.row([
PosColumn(text: item.name, width: 7),
PosColumn(
text: item.qty.toString(),
width: 1,
styles: const PosStyles(align: PosAlign.center),
),
PosColumn(
text: rupiah(item.lineTotal),
width: 4,
styles: const PosStyles(align: PosAlign.right),
),
]),
])
.toList();
}
// PosColumn widths always sum to 12. That grid — not pixels — is how
// esc_pos_utils_plus expresses layout, and it maps onto whatever column
// count the paper size implies.Perhatikan API baris. Lebar PosColumn selalu berjumlah dua belas, jadi baris nama-jumlah-nominal adalah pembagian 7-1-4 terlepas dari ukuran kertas, dan generator mengubah proporsi itu menjadi jumlah kolom sesungguhnya. Berpikir dalam per-dua-belas itulah yang membuat satu builder bisa melayani perangkat 58 mm dan 80 mm secara jujur.
Berikan ukuran kertas sebagai argumen konstruktor builder dan buat instansnya per printer, bukan per aplikasi. Toko mencampur perangkat keras — unit 80 mm di meja dan portabel 58 mm untuk pengantaran — dan satu generator global adalah cara struk pengantaran berakhir tidak sejajar.
Bekerja di dalam 32 kolom benar-benar sempit. Empat aturan berikut bertahan di setiap tata letak yang pernah saya rilis.
Satu-satunya pengecualian adalah header. Nama toko yang dirata-tengah dan berukuran ganda pantas memakan kolom karena itulah satu-satunya baris yang harus terbaca dari kejauhan.
Format mata uang tampak seperti urusan tampilan padahal sebenarnya batasan pencetakan: lebar string-nya menentukan berapa banyak ruang yang tersisa untuk nama barang. Simpan satu fungsi, pakai untuk struk dan layar, dan perlakukan lebar keluarannya sebagai masukan bagi tata letak Anda.
/// Rupiah formatting belongs in one place, and it is a printing
/// concern as much as a display concern: the string width decides
/// the column arithmetic.
String rupiah(int amount) {
final digits = amount.abs().toString();
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 == 0) buffer.write('.');
buffer.write(digits[i]);
}
return (amount < 0 ? '-' : '') + buffer.toString();
}
// rupiah(1250000) -> "1.250.000" 9 characters
// On a 32-column receipt that leaves 22 columns for the item name
// plus one separating space. Budget it explicitly, never hopefully.Memformat di perangkat juga menghindari kegagalan halus yang sudah dua kali saya temui: server mengirim string nominal yang sudah diformat dengan spasi non-breaking atau simbol mata uang yang tidak bisa dirender code page printer. Kirim angka lewat jaringan, format di tepian.
Jangan pernah membiarkan formatter yang sadar-locale memilih pemisah saat mencetak. Perangkat yang diatur ke bahasa Inggris menghasilkan pengelompokan berbeda dari yang diatur ke bahasa Indonesia, dan transaksi yang sama akan mencetak dua total yang tampak berbeda di dua kasir dalam satu toko. Kunci formatnya secara eksplisit.

Generator menyediakan kode QR dan barcode sebagai perintah printer native alih-alih gambar, dan itu penting di printer 58 mm bertenaga baterai: QR native cepat dan tajam, sementara kode yang sama dikirim sebagai bitmap menjadi lambat dan bergantung pada dithering Anda.
// QR codes and barcodes are native commands, not images. Use them.
...generator.qrcode('https://toko.example/r/8f2c1a', size: QRSize.size6),
...generator.barcode(Barcode.code128('SR-2026-0818'.codeUnits)),
// A logo, if you truly need one, is a raster image and costs real time
// on a 58 mm battery printer. Downscale it first and print it once at
// the top, never as a repeating decoration.
final logo = decodeImage(await rootBundle
.load('assets/logo_mono.png')
.then((d) => d.buffer.asUint8List()));
...generator.image(logo!);Logo raster adalah satu hal yang layak diperdebatkan dengan klien. Ia operasi paling lambat yang dilakukan printer, menguras baterai secara terukur, dan di kertas termal memudar lebih cepat daripada teks. Kalau toko bersikeras, cetak sekali di bagian atas, dalam gambar monokrom yang sudah disiapkan tepat pada lebar target, jangan pernah diskalakan saat runtime.
Begitu builder mengembalikan byte, mengujinya menjadi sepele sekaligus sangat berharga. Fixture berisi daftar byte persis yang di-commit mengubah setiap perubahan tata letak yang tidak disengaja menjadi tes yang gagal, bukan keluhan dari toko tiga minggu kemudian.
test('receipt fits 32 columns and ends with a cut', () {
final bytes = ReceiptBuilder(generator).build(sampleSale);
// Golden test: the exact byte list is committed as a fixture, so any
// accidental layout change shows up as a diff instead of as a
// complaint from a shop three weeks later.
expect(bytes, equals(goldenReceiptBytes));
});
test('long product names never push the amount off the line', () {
final line = itemLine('Nasi Goreng Spesial Telur Mata Sapi', '150.000');
expect(line.length, lessThanOrEqualTo(32));
expect(line.endsWith('150.000'), isTrue);
});Pasangkan golden test dengan beberapa pemeriksaan bergaya properti: nama produk terpanjang yang masuk akal tetap menyisakan nominal utuh, dan setiap baris tetap berada di dalam anggaran kolom. Dua tes itu menangkap lebih banyak cacat nyata bagi saya daripada sebanyak apa pun pencetakan manual.
Receipt builder adalah potongan kode yang kecil, murni, dan tersepesifikasi baik, dan memperlakukannya demikian yang membuat pencetakan termal jadi membosankan dalam arti positif. Jauhkan dari lapisan Bluetooth, berpikirlah dalam per-dua-belas, kunci format mata uang Anda, dan biarkan golden fixture menjaga tata letak yang Anda susun susah payah selama satu sore.