BonLab
A cheap 58 mm thermal printer showed up at my door. I paired it with my iPhone, opened the vendor app, and got… a cash register. Item name, price, total, cut. Fine for a shop, useless for everything else I actually wanted to print: a to-do list, a web link with a QR code, a recipe from a blog.
That gap is what led to BonLab — an iPhone app that designs data-bound receipt templates and prints them over Bluetooth to ESC/POS hardware. But this post is not a product tour. It is about the rendering engine underneath: how to turn a structured document into the byte stream a thermal printer understands, and why that problem is harder than it looks.
Why I built a thermal printer app
Thermal receipt printers are everywhere. They are cheap, instant, and satisfying in a way PDF printing never is. Yet almost every app that talks to them assumes you are running a point-of-sale system: fixed columns, fixed fonts, fixed layout.
The thesis behind BonLab is simpler: a thermal receipt is just a narrow document. It has text, dividers, side-by-side columns, tables, images, QR codes, and barcodes — arranged top to bottom on paper roughly 58 mm wide. Nothing about that requires a cash register mental model.
BonLab wraps that idea in a visual editor, a JSON template system, and a share-to-print pipeline. But the interesting engineering lives one layer down: a headless ESC/POS rendering pipeline that turns an immutable document tree into bytes, independent of Bluetooth, SwiftUI, or any particular app shell.
ESC/POS in 60 seconds
ESC/POS (Epson Standard Code for Point of Sale) is a byte-oriented command protocol understood by most inexpensive thermal printers. You send a sequence of commands — initialize, set bold, print text, feed paper, cut — and the printer executes them in order.
This is fundamentally different from “document printing” via PDF or AirPrint. ESC/POS is stateful and sequential. Turn bold on, and the printer stays bold until you turn it off. Select a code page, and subsequent text is interpreted under that encoding. The printer is a small computer with persistent style state, not a stateless line printer.
A minimal print job looks like this at the byte level:
| Command | Bytes | Effect |
|---|---|---|
| Initialize | 1B 40 | Reset printer to power-on defaults |
| Bold on | 1B 45 01 | Enable emphasized text |
| Text + line feed | "Hello" + 0A | Print a line |
| Bold off | 1B 45 00 | Disable emphasized text |
| Full cut | 1D 56 00 | Cut the paper |
There is also a platform constraint worth knowing if you build for iPhone. Most cheap “Bluetooth” thermal printers sold online use Bluetooth Classic SPP — a serial port profile that Android apps open directly. iOS does not expose that API to third-party apps unless the printer has passed Apple’s MFi certification. For everyone else, the supported path is Bluetooth Low Energy (BLE): the printer exposes a GATT service with a writable characteristic, and you stream ESC/POS bytes to it as if it were a UART.
BonLab pivoted to BLE early in development for exactly this reason. The rendering pipeline produces a Data buffer; a thin transport layer writes those bytes to the printer’s GATT characteristic. The document model never knows which transport is active.
The core insight: prints as a node tree
The central data structure is a ReceiptDocument: an immutable tree of typed nodes. Each node is one renderable element — text, a line break, paper feed, cut, divider, columns, table, image, QR code, barcode, or a style scope wrapping child nodes.
Why a tree instead of a string template?
- Layout requires measurement. Columns and tables need to know how many character cells fit on a line before any bytes are emitted. You cannot compute column widths while streaming ESC/POS commands.
- Styles compose hierarchically. Document defaults layer on top of printer defaults; named styles layer on top of those; inline overrides layer on top of named styles. A tree with scoped nodes expresses this naturally.
- The same structure drives preview and print. One document model feeds both the byte renderer and the on-screen preview renderer. No drift between “what you see” and “what prints.”
Here is a minimal document in JSON-like pseudocode:
{
"nodes": [
{
"type": "text",
"value": "BonLab",
"style": { "bold": true, "alignment": "center" }
},
{ "type": "divider", "char": "─" },
{
"type": "columns",
"widths": [0.6, 0.4],
"cells": ["Item", "$4.50"]
}
]
}
The critical design rule: the document model is transport-agnostic. It knows nothing about ESC/POS bytes, Bluetooth, or GATT characteristics. Higher layers — a visual editor, a share-import pipeline — produce documents or templates and hand them to the renderer. They never touch command encoding directly.
Layout before encoding
Standard 58 mm thermal paper has a printable width of roughly 384 dots at 203 dpi. Font A typically gives you 32 monospace character cells per line (12 dots each); Font B gives 42 (9 dots each). Every layout decision must fit inside that grid.
Layout runs before the renderer emits any bytes:
- Text wrapping splits long strings into lines that fit the available character count, respecting word boundaries and alignment padding.
- Columns divide the line into proportional widths. Each cell is wrapped independently, then the lines are stitched together with space padding so columns align vertically.
- Tables add borders and headers, with uniform cell scaling so every column shares the same character budget.
The output of layout is an array of printable text lines (or a monochrome bitmap for images). The renderer never guesses column widths at byte-encoding time.
Here is what a two-column row looks like on 32-character paper:
Before layout:
cells: ["Fresh basil (large bunch)", "$4.50"]
widths: [0.6, 0.4]
After layout (19 + 13 chars):
"Fresh basil (large $4.50"
" bunch)"
The layout engine handles the wrapping; the renderer receives finished lines and encodes them.
The renderer: stateful printers need state tracking
This is the heart of the pipeline. ESC/POS printers retain style state — bold, underline, font, alignment, character scale, code page. A naive renderer that emits “set bold” before every line works, but wastes bytes and can desync if a command is dropped.
BonLab’s renderer maintains a virtual printer state and emits only the diff commands needed to reach each node’s target style.
For a single text node, the steps are:
- Resolve the final style. Start from SDK defaults, apply the printer profile’s defaults, then document defaults, then any named style, then inline overrides.
- Diff virtual state → target state. Compare the running printer state to the resolved style. Emit only the commands that changed — bold toggle, alignment change, font select, and so on.
- Encode the text for the active code page. Characters the printer cannot represent are replaced or cause a hard failure, depending on policy.
- Append a line feed.
The diff logic compares each style field independently and emits the minimal command set:
// Simplified from ESCPosRenderer — emits only changed fields.
func commands(from old: ResolvedTextStyle?, to new: ResolvedTextStyle) -> Data {
var output = Data()
if old?.font != new.font { output.append(CommandEncoder.font(new.font)) }
if old?.alignment != new.alignment { output.append(CommandEncoder.alignment(new.alignment)) }
if old?.bold != new.bold { output.append(CommandEncoder.bold(new.bold)) }
if old?.underline != new.underline { output.append(CommandEncoder.underline(new.underline)) }
// ... width/height scale, code page, invert ...
return output
}
Style scopes nest naturally. A scope node applies an additional style layer to its children. When the renderer exits the scope and processes the next sibling, it diffs back to the outer base style — no explicit “reset all” command required.
Raw byte nodes are the escape hatch. They append vendor-specific command sequences directly and invalidate the virtual state, forcing a full re-emit on the next styled node. Useful for printer-specific features the generic encoder does not cover.
The command encoder itself is a thin, stateless layer of byte factories:
enum CommandEncoder {
static let initialize = Data([0x1B, 0x40]) // ESC @
static let lineFeed = Data([0x0A]) // LF
static func bold(_ on: Bool) -> Data { Data([0x1B, 0x45, on ? 1 : 0]) }
static func alignment(_ a: TextAlignment) -> Data { /* ESC a n */ }
static func cut(_ mode: CutMode) -> Data { /* GS V m */ }
}
Templates: data-bound prints without code
The same layout printed repeatedly with different data — a recipe, a web link, a plain note — calls for a template layer.
A ReceiptTemplate is JSON that describes a document structure with placeholders. A TemplateEngine renders it against runtime data into a ReceiptDocument:
- Expressions:
{{ path | formatter }}resolves a dotted path from a JSON data bag and passes it through a registered formatter. Example:{{ recipe.name | uppercase }}. - Control flow:
ifblocks render content only when a path is truthy.eachblocks repeat content once per item in a list. - Formatters: currency, uppercase, lowercase — registered by name. No arbitrary code evaluation.
- Missing-value policies: fail hard, substitute an empty string, or insert a fixed placeholder.
BonLab ships a recipe template that demonstrates all of this. Conditional sections show the title, author, and timing only when data is present. Ingredient and step lists use each loops:
{ "type": "text", "value": "INGREDIENTS", "styles": ["heading"] },
{ "type": "each", "source": "recipe.ingredients", "content": [
{ "type": "text", "value": "- {{text}}" }
] },
{ "type": "text", "value": "STEPS", "styles": ["heading"] },
{ "type": "each", "source": "recipe.steps", "content": [
{ "type": "text", "value": "{{text}}" },
{ "type": "feed", "lines": 1 }
] }
The full pipeline:
JSON data + JSON template → TemplateEngine → ReceiptDocument → ESCPosRenderer → bytes
When a user shares a recipe URL into BonLab, the import pipeline fetches the page, extracts schema.org Recipe structured data (title, ingredients, steps), and feeds it into this template. The template engine does not know or care where the data came from.
Thermal graphics: when text is not enough
Images, QR codes, and barcodes on thermal paper are monochrome bitmaps, not vectors. Color does not exist. Anti-aliasing does not exist. You get black dots or white dots.
The image pipeline:
decode → composite onto white → resize to printable width →
grayscale → threshold or dither → pack 1-bit pixels → GS v 0 raster command
Dithering matters. A simple brightness threshold turns a photograph into a harsh black-and-white blob. Floyd–Steinberg error diffusion distributes quantization error to neighboring pixels, preserving detail at the cost of a grainy texture. On receipt paper, that grain is usually preferable to losing the image entirely.
For QR codes and barcodes, the renderer tries two strategies:
- Native ESC/POS commands when the printer supports them (
GS ( kfor QR, symbology-specific sequences for barcodes). Faster and sharper because the printer renders the code in firmware. - Raster fallback when native encoding is unavailable. The code is generated as a bitmap — using the same dithering pipeline as photos — and sent as a
GS v 0raster image.
Large images produce large byte buffers. The current renderer emits one contiguous Data object per document; streaming and chunking for oversized graphics is a known future improvement.
Faithful preview without a printer
Users need to see the result before wasting paper. BonLab’s ReceiptPreviewRenderer draws the document to a bitmap at printer-dot resolution, using the same style resolution, text wrapping, column/table layout, image dithering, and QR/barcode rasterization as the byte renderer.
The preview is honestly labeled an approximation. Printer fonts differ from the monospaced screen font. But geometry matches: column widths, line breaks, scaling, and bitmap placement are identical. Preview/print drift — where the on-screen version looks nothing like the physical output — is a common failure mode in printing apps. Sharing one layout path between preview and print avoids it.
Figure to capture during editing: a side-by-side screenshot of the in-app preview next to a photo of the printed receipt. Same template, same data — geometry should match even if glyph shapes differ slightly.
BonLab: what sits on top of the engine
The rendering pipeline is deliberately headless. Three application-layer features consume it without knowing how bytes are produced:
Visual editor. An editable row model in the UI lowers into a ReceiptDocument or ReceiptTemplate at the boundary. Visual mode shows simulated print results; Advanced mode exposes every option per row. Both modes edit the same receipt.
Share import. A thin iOS Share Extension captures URLs and text into an App Group queue. The host app drains the queue through a fixed pipeline: normalize → enrich → route → fill → preview → print. Enrichment currently includes Open Graph meta tags and schema.org Recipe JSON-LD extraction — deterministic HTML parsing, not machine learning.
New Recipe flow. A guided fill-and-print path from the Print tab: pick a template, paste a URL to auto-fill fields, edit placeholders, preview, print.
The layering principle is strict: the editor and import pipeline never import the command encoder, renderer internals, or transport implementations. They produce documents or template data; the SDK handles everything downstream.
Lessons learned
Building this pipeline surfaced a few insights worth keeping:
- Treat the printer as stateful hardware. Style diffing saves bytes and prevents desync when the virtual state and physical state diverge.
- Separate document, layout, and encoding. One immutable document model powers templates, preview, and print. Layout produces lines; the renderer produces bytes. Neither leaks into the other.
- Platform constraints shape architecture. iOS forced BLE over Classic SPP. Designing transports as injectable protocols meant the pivot did not touch the renderer.
- Thermal graphics are a raster problem. Plan for dithering, width clamping, and raster fallbacks from the start. Native QR/barcode commands are a bonus, not a guarantee.
- Templates need guardrails. Restricted expressions and explicit missing-value policies beat embedding a scripting language in JSON.
- Share extensions should stay thin. Ingest only; fetch, enrich, and print in the host app. Extension memory limits are real.
- Document your pivots. Architecture decision records capture why choices changed — the BLE transport pivot, deferred test targets, tab shell refactor — so future-you remembers the context.
What is next
Planned but not yet shipped:
- on-device AI text enrichment for free-form share input
- support of json/csv for mass printing
- dynamic expressions with js
- introduction of counters to be able to print sequences
- random string/number generator