CrossEscPos

A receipt printer emulator for testing ESC/POS code. Point your point-of-sale software at it over TCP, serial or USB, and the receipt prints on screen.

It started as a Windows-only WPF app. This is the Avalonia 12 port: one codebase that runs on Windows, macOS, Linux and in the browser.

A receipt hanging upside down from the printer, reading CrossEscPos, ported from WPF on .NET 9 to Avalonia 12 on .NET 10, running on Windows, macOS, Linux and the browser, with a QR code.
Printed by the emulator from an ESC/POS job. Like a real receipt, it feeds out title first and hangs upside down. Scan the code to open the browser version.

Before and after

The original WPF app: a grey sidebar with Reset and Test print buttons beside plain text receipts.
Before: WPF on Windows only
The Avalonia app on macOS with connection settings, a receipt with text styles and barcodes, and the printer state panel.
After: Avalonia 12 on macOS
The same app on Linux rendering QR, PDF417, DataMatrix and Aztec codes.
After: the same app on Linux
The same app in a browser tab, connected to a TCP proxy and showing a received receipt.
After: the same app in the browser (WebAssembly)

What the migration cost

Starting point

The upstream app, roydejong/EscPosEmulator, targeted net9.0-windows7.0 with UseWPF: 48 C# and XAML files, about 2,400 lines. Windows was wired in at four levels.

  • Rendering. Every receipt line drew itself with GDI+ (System.Drawing), which is Windows-only on .NET 6 and later. The GDI+ types were part of the core interface, IReceiptPrintable.Render(Bitmap, Graphics, int, int). To show a receipt, the window saved each bitmap to a BMP stream and loaded it back as a WPF BitmapImage.
  • UI. Code-behind only. The window created Image controls by hand, found them again by a GUID-based name, and added or removed them from a StackPanel.
  • OS calls. A user32!FlashWindow P/Invoke and SystemSounds signalled new jobs.
  • Assumptions. TCP was the only transport, and the test receipt was read from the current working directory.

The ESC/POS interpreter (one command class per opcode) had no UI or Windows dependency, so its design carried over unchanged. It is now the headless CrossEscPos.Core package.

What changed, and what each part took

AreaWPF originalAvalonia portWhat it took
RenderingGDI+ Bitmap and GraphicsSkiaSharp behind a backend-neutral IReceiptCanvas, plus a fully managed ImageSharp backendThe biggest single job. The text-line renderer was rewritten. System fonts differ per OS, so the same receipt measured differently on each; embedding JetBrains Mono made output identical everywhere.
UIXAML and code-behindAXAML and MVVM (CommunityToolkit.Mvvm), with receipts bound to a reusable ReceiptViewThe markup ported almost line for line. The work was moving code-behind into view models and bindings.
Win32 callsFlashWindow, SystemSoundsA notification service: afplay, Console.Beep or paplay per OS, plus an in-window toastThere is no cross-platform system-sound API, so each OS gets its own strategy.
FilesRelative to the working directoryAvalonia StorageProvider for PNG export; app-relative asset pathsThe working-directory assumption broke in the packaged app and was fixed right after the first release.
TransportsTCPTCP and serial; the built-in Monitor adds direct USB through libusbPort names differ per OS. A Homebrew-installed libusb wasn't found until the app added the usual install paths to the native search path at startup.
PackagingOne Windows .exeSelf-contained builds for four targets from one CI matrix, including a macOS .appUnsigned macOS bundles were reported as damaged, so the bundle is ad-hoc signed and the README explains how to clear the quarantine flag.
BrowserNoneThe same app as an Avalonia WebAssembly headA browser can't listen on TCP, so an ASP.NET Core SignalR host opens the socket and relays jobs to the page. Serial and USB use Web Serial and WebUSB. Export downloads a file instead of opening a save dialog.

The numbers

Seven days with commits over five weeks, according to the git history:

  1. The port itself and desktop feature parity (PRs #1–#7).
  2. Split into layered packages with a swappable render backend (#8–#10).
  3. Managed ImageSharp backend, then the browser version (#11–#16).
  • From 48 files and about 2,400 lines to 168 files and about 10,000 lines of C# and AXAML, across 11 projects, 2 samples and 2 test projects. Most of the growth is new features, such as barcodes, 2D codes, status replies, printer-state simulation, the Monitor and the browser version, not port overhead.
  • 131 xUnit tests (89 test methods) run the interpreter against a synthetic render backend, which proves the core runs headless. The controls are tested with Avalonia.Headless.
  • Built with AI assistance (Claude Code). The commits say so in their Co-Authored-By trailers.

What was easy, and what hurt

Easy: XAML to AXAML, the Fluent theme, Dispatcher.UIThread and headless UI testing. The UI layer was the smallest part of the port.

Hurt: removing GDI+. It was part of the core interfaces, not just the view, so the renderer had to be redesigned before anything else could move. After that came native dependencies that differ per OS (libusb, fontconfig on Linux, macOS signing) and the browser sandbox, which has no sockets and no raw file system.

Would do again: put the drawing surface behind an interface first. Once IReceiptCanvas existed, the ImageSharp backend (a community contribution) and the browser version each landed within a few days.

Downloads

No .NET install is needed. On macOS, if the app is reported as damaged, run xattr -dr com.apple.quarantine CrossEscPos.app once. In the browser, TCP printing needs the small relay host: run dotnet run --project samples/CrossEscPos.Host and open the page it serves.