Skip to content

Printing a logo on a thermal receipt printer from React Native

A thermal printer only takes dots, so the logo has to become a one-bit bitmap, packed eight dots to a byte and placed by hand in paper units, with no preview.

5 min read7 sections

In 2022 I worked on Android payment terminals for a cross-border remittance service. A recipient collects cash at a filling station using a coupon code, and the terminal prints that coupon. Over a hundred of those terminals ended up in the field, locked to a single app and updated over the network.

Most of that project was about control: a launcher that replaced the Android home screen, updates that could fail without anyone noticing, a coupon that isn't valid until the system says so. This post is about the smallest part of it, one I expected to take an afternoon: putting the logo on the receipt.

01

The device

The terminal was a Chinese device running Android. Its SDK documentation was sparse and written in Spanish, and I translated it in order to work with it at all. The terminal's card, printer and other hardware functions were exposed to React Native through a Turbo Module written in Java.

So the printer was reachable, and printing text was not the problem. The problem started when the design had a logo in it.

02

A receipt printer only takes dots

A receipt printer does not know about images. It doesn't know about fonts the way a browser does, and it has no idea what layout means. It only accepts dots.

Everything else is something you build on top of dots yourself: the idea of a picture, a margin, something being centered. On a phone screen, a logo is a PNG you drop into a component and let the layout engine place. On the paper, there is no layout engine. There is a row of heating elements and a strip of paper moving past it.

That is where the difficulty lies: almost every habit a web or mobile developer has about images assumes a renderer in the middle, and on this hardware there isn't one.

03

Some general background

What follows is general knowledge about thermal printers, not a description of the terminal we used. I'm including it because it explains why the job looks the way it does.

A thermal print head is a fixed row of dots across the width of the paper. Each dot is either heated or not, so each printed pixel is one bit: black or nothing. There's no gray, and no color.

Printers in the ESC/POS family, and many vendor SDKs that wrap them, generally accept images as a raster: a sequence of rows, each row packed into bytes, eight dots to a byte. Width is fixed by the head. Height is however many rows you send.

So before anything reaches the printer, a logo has to go through three steps that a screen would never ask of you:

  1. Scaled to a width measured in printer dots, not in points or pixels
  2. Reduced to one bit per pixel, which is a decision about what counts as black
  3. Packed into the row format the printer or its SDK expects
04

Turning a logo into dots

The second step is where logos go wrong, because a logo designed for a screen almost always has anti-aliased edges, soft shadows or color. Reduced to one bit, those turn into ragged outlines or gray smudges that print as noise.

The simplest reduction is a threshold on luminance. A sketch of the idea in Java, on Android:

Snippet.java
Java · 18 lines
static boolean[][] toMonochrome(Bitmap src, int threshold) {    int w = src.getWidth();    int h = src.getHeight();    boolean[][] dots = new boolean[h][w];     for (int y = 0; y < h; y++) {        for (int x = 0; x < w; x++) {            int c = src.getPixel(x, y);            int alpha = Color.alpha(c);            int lum = (Color.red(c) * 299                     + Color.green(c) * 587                     + Color.blue(c) * 114) / 1000;            // transparent counts as paper, not as ink            dots[y][x] = alpha > 127 && lum < threshold;        }    }    return dots;}

And packing one row into bytes, eight dots each, most significant bit first:

Snippet.java
Java · 9 lines
static byte[] packRow(boolean[] row) {    byte[] out = new byte[(row.length + 7) / 8];    for (int x = 0; x < row.length; x++) {        if (row[x]) {            out[x / 8] |= (byte) (0x80 >> (x % 8));        }    }    return out;}

Neither of these is clever. The code is short, and it still doesn't get you a good receipt. The transparent-pixel line matters because a transparent background has no color of its own, and a converter that treats it as dark prints a solid black rectangle.

The better fix comes before the code: a logo designed in one bit from the start beats any conversion at print time. If you can get a one-bit version of the logo from whoever owns the brand, the threshold stops mattering.

05

Positioning by hand, with no preview

Getting the dots right is half of it. The other half is putting them in the right place.

On a screen you center a logo with one property. On the receipt you position it by hand, in paper units: where it starts across the width, how much space sits above and below it, how it lines up with the text that follows. There is no preview either: no inspector, no hot reload, and nothing to look at until the paper comes out.

That leaves one way to do it: print and measure. Change a value, print a coupon, hold it next to a ruler or the previous one, and change the value again. It sounds trivial until you try it, and then it's an afternoon of paper on the desk.

06

Where the numbers should live

This is where the printing connects to the rest of the project.

The approach on that terminal was the same as on every piece of hardware I've bridged to React Native: implement the device-specific part natively, expose a narrow surface to JavaScript, and keep platform detail from leaking upward. Dot widths, offsets in paper units and thresholds are platform detail. They belong to this printer, on this paper.

So the shape I'd give the JavaScript side is something like this, as a Turbo Module spec:

snippet.ts
TypeScript · 8 lines
import type { TurboModule } from 'react-native'import { TurboModuleRegistry } from 'react-native' export interface Spec extends TurboModule {  printCoupon(code: string, lines: ReadonlyArray<string>): Promise<void>} export default TurboModuleRegistry.getEnforcing<Spec>('TerminalPrinter')

JavaScript says what to print. The native side knows how, including where the logo goes and how big it is. If the measurements lived in JavaScript, every screen that printed anything would carry numbers that are only true for one device, and changing the paper or the terminal would mean hunting them down across the app. If that surface is wrong, every feature added later pays for it.

07

What I'd tell someone starting

Ask for a one-bit logo before you write a converter. That takes the hardest judgment call out of the code.

Treat the first print as a measurement. Print something whose dimensions you know, measure it, and write the numbers down next to the code that uses them. You're going to calibrate by printing anyway, so do it once, on purpose, instead of forty times by accident.

Keep every paper unit on the native side. The JavaScript surface should not know that paper exists.

It is a small problem, but the parts of a project that sound trivial are often the ones where none of your usual tools apply.

Where this came fromWork on Android payment terminals for a cross-border remittance service, where over a hundred devices were locked to a single app and updated over the network.Read the case study →Related serviceHardware integration in React NativeView service →Next postOur design system as a skill made an AI agent at least 2× fasterRead →

Writing

If you’re working on something similar, send us the details.

The first call takes 30 minutes, and you’re under no obligation.

Tell us about your projectcontact@microtech.consulting