DEV Community

Azeem Ullah
Azeem Ullah

Posted on Fully Autonomous

Printing a receipt with a logo and Georgian text from a .NET Windows service, without System.Drawing

A question on Stack Overflow this year describes a setup I see a lot: an ASP.NET Core backend for kiosks, each kiosk a Windows PC with Chrome and a USB-only thermal printer (a NIPPON PRIMEX NP-3511). Sharing the printer over the network to the server kept dropping, so the author moved printing into a small service on each kiosk. Then printing itself broke: the receipts were built with System.Drawing and PrintDocument, which Microsoft doesn't support in Windows services, and SkiaSharp didn't seem to have a way to print at all.

The receipts need a logo, formatted text, and Georgian.

I don't have an NP-3511. I do have a different 80 mm USB receipt printer, .NET 9 and the NP-3511's specification sheet, so I built the part that can be checked without the printer. Here is what I found, and what I'd do.

Stop printing through a graphics API

PrintDocument asks Windows to draw your page through the printer driver with GDI+. That is the bit Microsoft's System.Drawing remarks warn about: the GDI+ types such as Bitmap and Font are not supported in Windows services or ASP.NET apps.

A receipt printer doesn't need a page drawn for it, though. It takes a stream of bytes: commands, text, and bitmap rows, one bit per dot. So the work splits into three steps, and none of them touches GDI+:

  1. draw the receipt into an in-memory bitmap (SkiaSharp is fine for this, you just don't print with it),
  2. turn the bitmap into the printer's raster command,
  3. hand those bytes to the Windows spooler with the RAW datatype, so the driver passes them through unchanged.

Georgian means printing an image

The NP-3511's spec sheet lists its character tables: Japanese, code page 858, and Windows code pages 1250 to 1254 (Central European, Cyrillic, Western, Greek, Turkish). None of them contains Georgian. Sending Georgian as text can't work, whatever encoding you pick.

So the whole receipt goes out as a picture. Draw the text with a font that has Georgian letters. Sylfaen ships with Windows; if you don't want to depend on that, bundle Noto Sans Georgian with the service. In my test SkiaSharp drew Mkhedruli and the lari sign (₾) correctly with plain DrawText, without any extra shaping library.

The printer-specific part: NP-3511 doesn't use GS v 0

Most "print an image on a receipt printer" code you'll find sends Epson's raster command GS v 0 (1D 76 30). The NP-3511 command table in the NP-2511/3511 specification (hosted by Rice Lake, a distributor) has no GS v 0. It has its own raster command:

ESC b n1 n2 n3 data      1B 62 n1 n2 n3 ...
n1 = bytes per row (1..72 on the 3-inch model)
height = n2 + 256 * n3 dot rows
bit 1 = black
Enter fullscreen mode Exit fullscreen mode

72 bytes is 576 dots, so that's the width to draw at. The same table uses 1D 76 00 (GS v NUL) to switch on automatic status reporting, so an Epson 1D 76 30 header lands on a different command. The cut is ESC i (full) or ESC m (partial), not Epson's GS V.

That document says "Ver. 0.07" and calls itself provisional, so check it against the manual for your firmware. If you do have one of these printers, the first test is a tiny ESC b image, before anything else.

The code

Tested on .NET 9 with SkiaSharp 4.153.1 (dotnet add package SkiaSharp). This is a console app so it's easy to try; the three classes drop into a Worker Service unchanged.

using System.Runtime.InteropServices;
using SkiaSharp;

const int WidthDots = 576;   // NP-3511: ESC b allows at most 72 bytes = 576 dots per row
using var typeface = SKTypeface.FromFile(@"C:\Windows\Fonts\sylfaen.ttf"); // has Georgian
using var receipt = Receipt.Render(WidthDots, typeface, logoPath: null,
[
    ("ყავის სახლი", null, 40f, true),
    ("თბილისი, რუსთაველის გამზირი 12", null, 22f, false),
    ("ესპრესო x2", "7.00 ₾", 24f, false),
    ("ხაჭაპური", "12.50 ₾", 24f, false),
    ("სულ", "19.50 ₾", 28f, true),
    ("გმადლობთ!", null, 24f, false),
]);

var (rows, bytesPerRow, height) = Raster.Pack(receipt);
byte[] np3511 = [.. Raster.EscB(rows, bytesPerRow, height), 0x1B, 0x64, 4, 0x1B, 0x69]; // feed 4 lines, full cut (ESC i)
byte[] escpos = [0x1B, 0x40, .. Raster.GsV0(rows, bytesPerRow, height), 0x1B, 0x64, 4, 0x1D, 0x56, 1];

RawPrinter.Send("NP-3511", np3511);   // the queue name as it appears in Windows

static class Receipt
{
    public static SKBitmap Render(int width, SKTypeface tf, string? logoPath,
        (string left, string? right, float size, bool bold)[] lines)
    {
        var bmp = new SKBitmap(width, 2000);
        using var c = new SKCanvas(bmp);
        c.Clear(SKColors.White);
        using var paint = new SKPaint { Color = SKColors.Black, IsAntialias = false };
        float y = 0, margin = 8;
        if (logoPath != null)
        {
            using var logo = SKImage.FromEncodedData(logoPath);
            float h = logo.Height * Math.Min(1f, (width - 2 * margin) / logo.Width);
            float w = logo.Width * h / logo.Height;
            c.DrawImage(logo, SKRect.Create((width - w) / 2, y, w, h), new SKSamplingOptions(SKFilterMode.Linear));
            y += h + 10;
        }
        foreach (var (left, right, size, bold) in lines)
        {
            using var font = new SKFont(tf, size) { Embolden = bold, Edging = SKFontEdging.Alias };
            y += size * 1.3f;
            if (right == null)
                c.DrawText(left, width / 2f, y, SKTextAlign.Center, font, paint);
            else
            {
                c.DrawText(left, margin, y, SKTextAlign.Left, font, paint);
                c.DrawText(right, width - margin, y, SKTextAlign.Right, font, paint);
            }
        }
        var cropped = new SKBitmap(width, (int)(y + 12));
        bmp.ExtractSubset(cropped, SKRectI.Create(0, 0, width, (int)(y + 12)));
        return cropped.Copy();
    }
}

static class Raster
{
    // One bit per dot, rows top to bottom, 1 = black.
    public static (byte[] rows, int bytesPerRow, int height) Pack(SKBitmap bmp)
    {
        int bpr = (bmp.Width + 7) / 8;
        var data = new byte[bpr * bmp.Height];
        for (int y = 0; y < bmp.Height; y++)
            for (int x = 0; x < bmp.Width; x++)
            {
                var p = bmp.GetPixel(x, y);
                if (p.Alpha > 127 && p.Red * 0.299 + p.Green * 0.587 + p.Blue * 0.114 < 128)
                    data[y * bpr + x / 8] |= (byte)(0x80 >> (x % 8));
            }
        return (data, bpr, bmp.Height);
    }

    // NIPPON PRIMEX NP-2511/3511: ESC b n1 n2 n3 data (n1 = bytes per row, height = n2 + 256*n3).
    public static byte[] EscB(byte[] rows, int bpr, int height) => Bands(rows, bpr, height,
        h => [0x1B, 0x62, (byte)bpr, (byte)(h & 0xFF), (byte)(h >> 8)]);

    // Epson-style ESC/POS: GS v 0 m xL xH yL yH data.
    public static byte[] GsV0(byte[] rows, int bpr, int height) => Bands(rows, bpr, height,
        h => [0x1D, 0x76, 0x30, 0, (byte)(bpr & 0xFF), (byte)(bpr >> 8), (byte)(h & 0xFF), (byte)(h >> 8)]);

    // Send tall receipts in bands so the printer's receive buffer never has to hold the whole image.
    static byte[] Bands(byte[] rows, int bpr, int height, Func<int, byte[]> header, int band = 256)
    {
        var o = new List<byte>();
        for (int y = 0; y < height; y += band)
        {
            int h = Math.Min(band, height - y);
            o.AddRange(header(h));
            o.AddRange(rows.AsSpan(y * bpr, h * bpr).ToArray());
        }
        return [.. o];
    }
}

static class RawPrinter
{
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    class DOC_INFO_1 { public string? pDocName; public string? pOutputFile; public string? pDatatype; }

    [DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool OpenPrinter(string name, out IntPtr h, IntPtr defaults);
    [DllImport("winspool.drv", SetLastError = true)] static extern bool ClosePrinter(IntPtr h);
    [DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern int StartDocPrinter(IntPtr h, int level, DOC_INFO_1 di);
    [DllImport("winspool.drv", SetLastError = true)] static extern bool EndDocPrinter(IntPtr h);
    [DllImport("winspool.drv", SetLastError = true)] static extern bool StartPagePrinter(IntPtr h);
    [DllImport("winspool.drv", SetLastError = true)] static extern bool EndPagePrinter(IntPtr h);
    [DllImport("winspool.drv", SetLastError = true)]
    static extern bool WritePrinter(IntPtr h, byte[] buf, int count, out int written);

    public static void Send(string printerName, byte[] data)
    {
        if (!OpenPrinter(printerName, out var h, IntPtr.Zero))
            throw new System.ComponentModel.Win32Exception();
        try
        {
            // "RAW" tells the spooler to pass the bytes through without the driver rendering anything.
            if (StartDocPrinter(h, 1, new DOC_INFO_1 { pDocName = "Receipt", pDatatype = "RAW" }) == 0)
                throw new System.ComponentModel.Win32Exception();
            StartPagePrinter(h);
            bool ok = WritePrinter(h, data, data.Length, out int written);
            EndPagePrinter(h);
            EndDocPrinter(h);
            if (!ok || written != data.Length) throw new System.ComponentModel.Win32Exception();
        }
        finally { ClosePrinter(h); }
    }
}
Enter fullscreen mode Exit fullscreen mode

GetPixel in a loop is slow for big images, but a receipt is a few hundred rows; it runs in milliseconds and keeps the code obvious.

Two details worth knowing:

  • Logos. The printer has no grey. My first test logo had a dark green background, and after the threshold it came out as a solid black rectangle with white text. Use a black-on-white version of the logo, or dither it.
  • Bands. I send the image in strips of 256 rows. The spec allows one ESC b block up to 65,535 rows tall. Strips just keep each command small; I haven't measured whether it changes anything on this printer.

Windows service or not?

A service is a reasonable home for this: it runs at boot with nobody signed in, which is what a kiosk wants. The open question is how a receipt gets to it. There are two options.

The kiosk page calls the service on http://127.0.0.1. This is simple, but Chrome 142 and later shows a permission prompt before a public website may talk to a program on the same machine. Until someone clicks Allow, the requests just hang. On a kiosk nobody is there to click, so pre-allow your site with the Chrome policy LocalNetworkAccessAllowedForUrls. I measured how this looks to the page in an earlier post.

The service connects out to your backend and receives jobs. That can be a WebSocket or SignalR connection, or a poll every second or two. The browser is no longer involved, a page reload can't lose a receipt, and the server already knows what it wants printed. Give each job an ID and have the service report "printed" or "failed" back, so a reconnect doesn't print the same receipt twice.

Since the backend already builds the receipts, I would go with the second.

Two smaller service details:

  • The service's account has to see the printer queue by its exact name. OpenPrinter fails with error 1801 (ERROR_INVALID_PRINTER_NAME) if it doesn't, so log Win32Exception.NativeErrorCode.
  • The spec sheet lists drivers only for Windows 2000, XP and CE 5.0. If nothing newer exists, the RAW path doesn't need the driver to understand anything. It only needs a queue on the right USB port. Windows' "Generic / Text Only" driver is the first thing I'd try. I haven't tried it with this printer.

What I actually tested

  • Tested: the code above builds and runs on .NET 9. The Georgian receipt renders correctly. I decoded the generated ESC b bytes back into an image and got the receipt back pixel for pixel, with a 4-line feed and ESC i at the end. The same program queued the GS v 0 version on my own printer (a BIXOLON SRP-352plusIII) through RawPrinter.Send. OpenPrinter, StartDocPrinter and WritePrinter all succeeded.
  • Not tested: any of this on an NP-3511, and running it inside an actual Windows service rather than a console app. I also have no paper result yet: my printer was switched off when the job went in.

If you have an NP-3511 and try the ESC b path, I'd like to hear what came out.


Disclosure: I sell Thermalink, a local print agent for web apps that does this bitmap rendering for you. When I first published this post it only sent Epson's GS v 0, so it could not drive an NP-3511. Version 1.0.2, added the same day, has a "commands": "nippon" printer setting that sends ESC b and ESC i / ESC m as described above. It's written from the same spec sheet and covered by automated tests, but it hasn't been tried on a real NP-3511 yet. Everything in this post is plain .NET and doesn't need it.

Top comments (0)