Printing a receipt on a thermal printer means sending it ESC/POS commands: a stream of bytes that says "bold on", "center this", "print this barcode", "cut the paper". For Latin text that is manageable. For Arabic it goes wrong quickly. The printer does not shape letters, so each one comes out in its isolated form, and it prints bytes in the order it receives them, so a right-to-left line comes out reversed. On top of that, the printer needs the text in a code page it knows, not in UTF-8.

escpos_arabic is a pure Dart package that builds those bytes and treats Arabic as a first-class case. It shapes the letters, reorders the line, encodes it to CP864 or Windows-1256, and also covers the rest of a receipt: layout, images, barcodes and QR codes. The README describes it as a maintained replacement for esc_pos_utils.

One thing to be clear about from the start: it only builds bytes. Sending them over Bluetooth, USB or the network is out of scope, and you pick the transport package you prefer.

What it does

A generator bound to a printer profile. You create a Generator for a PrinterProfile: 58 mm or 80 mm paper, characters per line for font A and B, code page numbers and beeper type. Every method returns a List<int>, so a receipt is a list you build with the spread operator.

The generator covers the usual commands: ESC @ init, bold, underline (1 or 2 dots), double-strike, reverse, font A/B, width and height multipliers from 1 to 8, alignment, line spacing, feed, full or partial cut, the cash drawer pulse and the beeper when the profile has one. It tracks style, alignment and code page changes, so it only sends the commands that changed.

Layout. row([PosColumn(text, flex/width, align)]) builds columns, with wrapping per cell by display width: East Asian wide characters take two cells and combining marks take none. hr() draws a rule and keyValue prints lines like Total ........ 1 250,00 DA.

Barcodes. UPC-A, UPC-E, EAN-13, EAN-8, CODE39, ITF, CODABAR and CODE128 (code sets A, B and C), with HRI position and font, height and module width. Input is validated: lengths, character sets, check digits and UPC-E zero suppression.

QR codes. They use the printer's native GS ( k command, with model, module size and error correction level.

Images. Grayscale conversion, then threshold, Floyd–Steinberg or Atkinson dithering, scaling to the printable width, GS v 0 raster sent in chunks, and an ESC * 24-dot bit image fallback.

Arabic, two strategies. This is the part the package is named after.

  • Code-page mode. arabicText picks the contextual presentation forms (isolated, initial, medial, final) and lam-alef ligatures, runs a simplified Unicode bidi pass so numbers and Latin stay left to right, then encodes to CP864 (shaped) or Windows-1256. The ESC t n number is configurable.
  • Raster mode (recommended). You render the line with Flutter's text engine and print it as an image. The README includes a renderTextLine helper built on dart:ui ParagraphBuilder that you can copy.

Both can be mixed in one receipt. Other code pages are there too: CP437, CP850, CP858 (with the euro sign) and CP1252, with a fallback character for anything a page cannot encode.

How to use it

Add it from GitHub:

dependencies: escpos_arabic: git: https://github.com/AbdeldjalilChougui/escpos_arabic 

Then build a receipt:

import 'package:escpos_arabic/escpos_arabic.dart'; List<int> buildReceipt() { final g = Generator(PrinterProfile.mm80, codePage: CodePage.cp858); return [ ...g.reset(), ...g.text('CAFE DU PORT', styles: const PosStyles( align: PosAlign.center, bold: true, width: 2, height: 2)), ...g.arabicText('مقهى الميناء', styles: const PosStyles(align: PosAlign.center)), ...g.hr(), ...g.row([ PosColumn('Café crème', flex: 3), PosColumn('x2', width: 4, align: PosAlign.right), PosColumn('400,00', width: 10, align: PosAlign.right), ]), ...g.keyValue('Total', '1 250,00 DA', styles: const PosStyles(bold: true)), ...g.arabicText('المجموع: 1 250,00 دج'), ...g.barcode(Barcode.ean13('5901234123457'), height: 60), ...g.qrCode('https://abdeldjalilchougui.biz/', size: 5), ...g.feed(2), ...g.cut(), ]; } 

The result is a plain list of bytes. For a network printer, the README sends it over raw TCP on port 9100:

Future<void> printOverTcp(List<int> bytes, String host) async { final socket = await Socket.connect(host, 9100, timeout: const Duration(seconds: 5)); socket.add(bytes); await socket.flush(); await socket.close(); } 

The README also has snippets for print_bluetooth_thermal 1.2 and flutter_thermal_printer 2.x, which is what the examples were checked with.

Code page numbers differ between printers

The number you send with ESC t to select a code page is not standard. The defaults are the Epson numbers (PC437 = 0, PC850 = 2, WPC1252 = 16, PC858 = 19, PC864 = 37, WPC1256 = 50). Other brands use other numbers, so print the printer's self-test page and put its numbers in the profile:

final profile = PrinterProfile.mm58.copyWith( codePages: { CodePage.cp437: 0, CodePage.cp858: 19, CodePage.cp864: 22, CodePage.cp1256: 50, }, beeper: BeeperType.epsonPattern, ); final g = Generator(profile, arabicCodePage: CodePage.cp864); 

Coming from esc_pos_utils

The README has a migration table. Most calls keep their shape: text with PosStyles is the same, PosStyles(height: PosTextSize.size2) becomes PosStyles(height: 2), qrcode becomes qrCode, drawer() becomes drawerKick(), and rows take flex or a width in characters instead of the 12-column grid. Profiles are plain Dart objects, so there is no asset loading.

Things to know

  • Not printed on hardware yet. The README says it directly: byte output was tested, but it was not printed on a real printer while writing the package. Command numbers follow the Epson reference, and clones differ. Some printers ignore GS v 0, GS ( k, UPC-E with 6 to 8 digits, or the beepers.
  • Use a no-break space inside numbers. In an Arabic line, separate digit groups with  , not a plain space. Following UAX #9, a plain space splits 1 250 into two numbers shown in right-to-left order (250 1).
  • CP864 cannot show every form. Missing forms use the closest one (medial becomes initial, final becomes isolated), and alef with hamza below is printed as alef. Diacritics are removed by default.
  • CP1256 usually prints unjoined letters. The printer receives unshaped base letters in visual order, and most printers do not join them. Prefer CP864 or raster mode.
  • The bidi pass is simplified. No explicit embeddings, overrides or isolates, no bracket pairing, and it works one line at a time. Shaping covers the presentation forms that exist in Unicode, with no OpenType features beyond lam-alef. For the best quality, use raster mode.
  • No CJK multi-byte mode. Wide characters are only measured for layout.
  • Bytes only. No printer discovery, connection or status reading.

For raster text, Dithering.threshold keeps the edges crisp. Keep Floyd–Steinberg and Atkinson for photos and logos.

Why I built it

esc_pos_utils had no Arabic support at all, and Arabic is the hard part of a receipt: the printer neither shapes letters nor reorders the line, so sending the string as it is gives unreadable output. Fixing that needs three things in the same place: the shaping tables, a bidi pass and the code page encoders. I wanted them next to the receipt commands, in pure Dart, with no native code and no assets to load, and with a second path (raster) for when a printer's code pages are not good enough.

The tables are not hand-typed. They are generated from the Unicode Character Database 18.0.0 and the code page mappings published by Unicode, with dart run tool/generate_tables.dart, and the command formats follow Epson's ESC/POS reference.

Get the code

The source, the README with the raster helper and the transport snippets, and a runnable example are on GitHub: https://github.com/AbdeldjalilChougui/escpos_arabic. It's MIT licensed. If you try it on a printer that behaves differently, an issue with the model and the self-test page is welcome.

Originally published at abdeldjalilchougui.biz.