Turn a formatted template string into ESC/POS bytes.
tk_mdpos::render(template: &str, profile: &Profile) -> Result<Vec<u8>, Error>That is the entire public contract.
Every other ESC/POS library is a command builder — you call .bold().text().align() from
your application. That compiles receipt layout into the binary, so changing a footer means
a rebuild, a redeploy, and a test cycle.
mdpos moves layout into a string. It can live in a database row, a config field, or a text area. Your application forwards it; the engine figures out the rest. Layout changes stop being releases.
The differentiator is the layout engine, not the parser: right-alignment computed in dots,
a grid that tracks magnification, per-column overflow policy, and widths measured with
unicode-width.
{v 1}
{center}
{size 2x2}TOKO MAJU
{size 1x1}Jl. Sudirman 42
---
{left}
{cols 20,10:r,12:r}
Nasi Goreng | 2 x 25.000 | 50.000
Es Teh Manis | 3 x 5.000 | 15.000
---
{cols 22,20:r}
**TOTAL** | **65.000**
{feed 4}
{cut}
mdpos --preview receipt.tmpl:
TOKO MAJU
Jl. Sudirman 42
------------------------------------------------
Nasi Goreng 2 x 25.000 50.000
Es Teh Manis 3 x 5.000 15.000
------------------------------------------------
TOTAL 65.000
[dependencies]
tk-mdpos = "0.1"The CLI and the C ABI are not published. Both live in this repository and are built from it:
cargo install --git https://github.com/terrakernel/tk-mdpos tk-mdpos-clitk-mdpos-ffi stays unpublished until the layout engine has been verified against real
hardware — an ABI is a compatibility anchor, and anchoring it before that would be premature.
use tk_mdpos::Profile;
let template = std::fs::read_to_string("receipt.tmpl")?; // or a database column
let profile = Profile::epson_80mm();
let bytes = tk_mdpos::render(&template, &profile)?;
send_to_printer(&bytes)?;Three entry points, all sharing the same parse and layout passes:
tk_mdpos::render(&t, &p)? // -> Vec<u8> ESC/POS bytes
tk_mdpos::preview(&t, &p)? // -> String monospace, for showing a user or a test
tk_mdpos::to_ops(&t, &p)? // -> Vec<Op> the IR, for tooling or a custom backendThe profile is a plain struct:
use tk_mdpos::{Font, Profile};
let narrow = Profile { width_dots: 384, ..Profile::epson_80mm() }; // 58mm, 32 columns
let dense = Profile { font: Font::B, ..Profile::epson_80mm() }; // 64 columnsprofile.columns() gives characters per line — always derived from width_dots and the
font, never hardcoded. columns_at(2) gives it under {size 2x2}.
mdpos receipt.tmpl > out.bin # ESC/POS bytes to stdout — always redirect
mdpos --preview receipt.tmpl # monospace preview
cat receipt.tmpl | mdpos - # read from stdinThe CLI uses Profile::epson_80mm(). There is no profile flag yet.
| Directive | Meaning |
|---|---|
{v 1} |
Format version. Optional, but must come first if present. |
{left} {center} {right} |
Justification. Sticky until changed. |
{size WxH} |
Character magnification, 1–8 each. Sticky. Halves the grid at 2x. |
{cols A,B:r,C:c} |
Column widths in characters. :l default, :r right, :c center. Sticky. |
{/cols} |
Leave column mode. |
--- |
Full-width rule. Three or more dashes, alone on the line. |
{feed N} |
Feed N lines. |
{cut} |
Partial cut. |
{raw 1D564200} |
Hex passthrough. Spaces allowed: {raw 1D 56 42 00}. |
**text** |
Bold. |
__text__ |
Underline. |
Directives may stand alone on a line or prefix one — {center}{size 2x2}TOKO MAJU works.
While a {cols} spec is active, lines split on | and the cell count must match the
spec exactly. Outside column mode, | is ordinary text.
Every document is self-contained: it begins with ESC @, ends with a feed and a cut, and
assumes nothing about the printer's prior state. A thermal printer is a stateful
interpreter — leave emphasis on and the next receipt prints bold until someone power
cycles it.
Whitespace is stripped from both ends of every line and cell. Nasi Goreng | 50.000
and Nasi Goreng|50.000 are identical. This is deliberate: templates live in database rows
and text areas that do not preserve trailing spaces, so alignment is stated with :r,
never implied by padding. If you genuinely need a leading space, escape it — \ Total.
\ is the only escape rule. It makes the next character literal: \|, \*, \_,
\{, \\.
Column widths are in current characters. Under {size 2x2} a width of 20 means 20
double-width characters — 40 base cells. So {cols 20,10:r,12:r} totals 42 and is fine at
1x, but is rejected at 2x, where only 24 columns exist.
Right-aligned columns never wrap. Overflow is an error, because a wrapped total prints as two lines that read as two different numbers:
line 3: "1.250.000" overflows right-aligned column 2 (width 6); right-aligned columns never wrap
Left and centered columns wrap, with continuation lines returning to the column's own start.
This library has no data binding, and will not grow any. It renders a finished string. Building that string — looping over line items, formatting currency, formatting dates — is your application's job, in whatever language it is already written in.
That is a deliberate limit rather than a missing feature. Mustache and Handlebars are data binding with no concept of bold or centre; once the caller has interpolated the data, a template with no tags left in it is just a string. There is nothing for mdpos to add.
let mut tmpl = String::from("{cols 24,10:r,12:r}\n");
for item in &order.items {
tmpl += &format!("{} | {} x {} | {}\n",
escape(&item.name), item.qty, money(item.price), money(item.total));
}Interpolated values become template source. A product genuinely named
Nasi Goreng | Spesial turns a three-cell row into four and the render fails with
ColumnCountMismatch; a name containing ** silently toggles bold.
The parser has one escape rule — \ makes the next character literal — so the guard is
short:
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '\\' | '|' | '*' | '_' | '{') {
out.push('\\');
}
out.push(c);
}
out
}| Value | Escaped | Prints as |
|---|---|---|
Nasi Goreng | Spesial |
Nasi Goreng \\| Spesial |
Nasi Goreng | Spesial |
**PROMO** Ayam |
\*\*PROMO\*\* Ayam |
**PROMO** Ayam |
Kopi_Susu__Gula |
Kopi\_Susu\_\_Gula |
Kopi_Susu__Gula |
{cut} Es Teh |
\{cut} Es Teh |
{cut} Es Teh |
Two things escaping does not cover:
- A value that is the entire content of a line and consists only of dashes becomes a
full-width rule. If that is reachable, prefix the line with a backslash:
\---prints three dashes. - Leading and trailing whitespace is stripped from every line and cell, so a value that
depends on it will lose it. Use
\for a deliberate leading space.
If you find yourself copying escape into a third place, that is the signal to make it a
small crate of its own — logic-less, escaping by default. Not part of this one.
Error implements Display and std::error::Error, and carries the 1-based source line.
Templates are edited by hand with no compiler in between, so surface the message verbatim
to whoever edits them.
The crate does not know what a printer is. No sockets, no serial ports, no USB, no filesystem, no async runtime. It produces bytes; delivering them is the caller's job.
This is not minimalism. Printer transport is a platform tarpit — the Windows spooler,
/dev/usb/lp0, BLE GATT, Bluetooth RFCOMM, and vendor AIDL services are all different
problems, and the largest Android hardware (Sunmi, iMin, Telpo) exposes nothing but
sendRAWData(byte[]). Producing bytes is the only thing that works everywhere.
mdpos receipt.tmpl > out.bin
cat out.bin > /dev/usb/lp0 # Linux USB
nc 192.168.1.50 9100 < out.bin # network / most WiFi printersFrom an application: write the bytes to a serial port, a TCP socket on port 9100, or hand them to a vendor SDK. Queueing, chunking, retries, job atomicity, and paper-out status polling all belong to the caller or to a separate crate.
tk-mdpos-ffi exposes the same contract across a flat C ABI, building libtk_mdpos.a and
libtk_mdpos.dylib/.so. The header is tk-mdpos-ffi/include/tk_mdpos.h.
TkMdposProfile profile = tk_mdpos_profile_epson_80mm();
TkMdposBuf out;
if (tk_mdpos_render((const uint8_t *)tmpl, strlen(tmpl), &profile, &out) == TK_MDPOS_OK) {
fwrite(out.ptr, 1, out.len, printer);
} else {
fprintf(stderr, "mdpos: %s\n", (const char *)out.ptr);
}
tk_mdpos_free(out); // required in both branchesThree rules:
- Every buffer must go back to
tk_mdpos_free, including the ones returned alongside an error. Rust allocated it and only Rust may release it — neverfree(). - Buffers are NUL-terminated at
ptr[len], andlenexcludes that byte. ESC/POS output contains embedded zeros (GS V 66 0ends in one), so%struncates the output — but can never read past the allocation. Uselen. - Errors come back as a message, not just a code. Template errors carry their source line and that text is for whoever edits the template.
Every entry point is wrapped in catch_unwind, since a panic unwinding across a C frame is
undefined behaviour. That requires panic = "unwind", which the workspace pins.
cargo build -p tk-mdpos-ffi --release
cc -Itk-mdpos-ffi/include tk-mdpos-ffi/tests/smoke.c target/debug/libtk_mdpos.a -o smoke && ./smokeThat C smoke test is what verifies the header still matches the compiled ABI; the Rust tests cannot catch header drift.
Nothing below has been built or verified yet — no cross targets are installed and there are no Kotlin or Swift wrappers in this repo. This is the path, with the landmines marked.
For size reference, the host build with lto = "fat", opt-level = "z" and stripped comes
to 311 KB exporting 7 symbols. There are no callbacks, no threads, and no I/O, which
is what makes this straightforward on both platforms.
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
# NDK r27 or newer; cargo-ndk writes straight into the Gradle layout
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
-o app/src/main/jniLibs build --release -p tk-mdpos-ffiThat produces libtk_mdpos.so per ABI and Gradle packages it automatically. Bind it with
either a hand-written JNI shim (extern "system" fn Java_..., faster at runtime) or JNA
against the existing C ABI (faster to get working, adds a dependency).
Then hand the bytes to the printer. On the vendor handhelds this is the whole reason the core is sans-IO:
val bytes: ByteArray = mdposRender(template, profile)
sunmiPrinterService.sendRAWData(bytes, null) // Sunmi, iMin, Telpo — same shape16KB page size. Android 15+ requires 16KB-aligned native libraries, and Rust does not do it for you:
-C link-arg=-Wl,-z,max-page-size=16384The trap is that omitting it passes local testing and fails Play review. Check the current Play policy rather than trusting this note — the deadlines here have moved more than once.
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
cargo build -p tk-mdpos-ffi --release --target aarch64-apple-iosPackage libtk_mdpos.a plus include/tk_mdpos.h and a module.modulemap as an XCFramework,
then consume it as a SwiftPM binaryTarget or drop it into Xcode. Swift calls the C ABI
directly — no shim needed. Bitcode has not been required since Xcode 14.
MFi. The blocker on iOS is hardware, not code. Bluetooth Classic printers require the printer vendor to be MFi-certified; BLE via CoreBluetooth has no such gate. Settle this before writing any Swift.
- Wrap the buffer in something with a destructor.
tk_mdpos_freemust run on every path, including errors. A Kotlinuse {}or a Swift type withdeinitmakes that structural instead of a thing you have to remember. - Keep
panic = "unwind". It costs roughly 100 KB againstpanic = "abort", but panics unwinding through a JNI or Swift frame are undefined behaviour andcatch_unwindcannot work without it. The workspace pins it deliberately. - Error messages are English UTF-8 carrying template line numbers. They are written for whoever edits the template, not for the customer holding the receipt. Surface them in your admin UI and log them; do not put them on screen at the till.
If you end up hand-writing both a Kotlin and a Swift wrapper, it is worth a few minutes looking at UniFFI, which generates both from the Rust API. It would largely replace this crate rather than sit on top of it, and for a surface of three functions that matter the hand-written C ABI is probably still the better trade — but make that call deliberately.
{raw HEX} is an escape hatch, not a hack. Clone printers — Xprinter, Rongta, EPPOS,
Gainscha — have no specification, and their deviations cluster in cut variants, ESC $
handling, and native QR. That is unfixable in principle, so {raw} means a vendor-specific
cut or drawer kick never blocks on a release.
Note that "standard ESC/POS" is not a standard. It is Epson's proprietary command set, copied to varying degrees, with no spec body and no certification. Compatibility claims here are meant to be falsifiable: print a test template and compare.
Templates may declare {v 1}. The string carries the compatibility promise, not the
crate version — the engine may be rewritten freely, but a v1 template must render
identically in perpetuity. If syntax changes could drag deployed templates back into the
redeploy cycle, the entire premise of this library collapses.
v0.1. The pipeline is complete end to end and covered by unit tests and golden fixtures.
Known limitations:
- Non-ASCII text is rejected, not mangled. The CP437 high range (0x80–0xFF) is not
mapped yet, so
caféreturns an error rather than printing wrong. Width measurement is alreadyunicode-widththroughout. - One built-in profile (80mm, Font A, Epson). No profile registry or TOML loading.
- Not yet verified against real hardware.
Out of scope for v0.1, and not merely deferred: WASM, QR codes, barcodes, images, data interpolation, and the Star, ZPL, and TSPL dialects. None of them can be evaluated sensibly until the layout engine has proven itself in print.
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
UPDATE_GOLDEN=1 cargo test --test golden # regenerate fixtures, then read the diffGolden fixtures live in tests/golden/ and are structured as if they will be published —
they are the seed of a conformance corpus and of a customer-facing compatibility test. Both
backends snapshot from the same input, which is what keeps the preview honest about what
the bytes will do.
A changed expected.bin is a v1 compatibility break until proven otherwise. Inspect the
diff by hand.
Copyright (c) 2026 TERRAKERNEL PTE. LTD.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.