A Rust library for Star Micronics TSP650II thermal receipt printers over Bluetooth. Implements the StarPRNT protocol with a React-inspired component system, an optimizing compiler, and a web UI for photo printing.
The web UI supports printing photos with real-time dithered preview:
.deb users convert to JPEG first)Documents can include images from URLs. When a document is submitted via the JSON API, images are automatically downloaded, cached, resized, and dithered for printing.
{"type": "image", "url": "https://example.com/photo.jpg"}
height field acts as a cap — if the resized image is taller, it shrinks to fit"align": "center"). Also accepts "left" or "right""dither" to "bayer", "atkinson", "jarvis", or "none"{
"type": "image",
"url": "https://example.com/photo.jpg",
"width": 400,
"height": 300,
"align": "center",
"dither": "atkinson"
}
Instead of manually constructing printer escape sequences, Estrella provides a declarative Document model. The same types work for both Rust construction and JSON deserialization — one set of types, zero conversion layer.
use estrella::document::*;
let doc = Document {
document: vec![
Component::Banner(Banner::new("CHURRA MART")),
Component::Text(Text { content: "2026-01-20 12:00:00".into(), center: true, ..Default::default() }),
Component::Spacer(Spacer::mm(3.0)),
Component::Banner(Banner { content: "TODAY ONLY: 50% OFF".into(), border: BorderStyle::Double, size: 2, ..Default::default() }),
Component::Divider(Divider::default()),
Component::LineItem(LineItem::new("Espresso", 4.50)),
Component::LineItem(LineItem::new("Croissant", 3.25)),
Component::Divider(Divider::default()),
Component::Total(Total { amount: 7.75, bold: Some(true), double_width: true, ..Default::default() }),
Component::Spacer(Spacer::mm(3.0)),
Component::QrCode(QrCode { data: "https://example.com/rewards".into(), cell_size: Some(6), ..Default::default() }),
Component::Text(Text { content: "Thank you!".into(), center: true, bold: true, ..Default::default() }),
],
cut: true,
..Default::default()
};
let bytes = doc.build(); // StarPRNT bytes, ready to send
let json = serde_json::to_string(&doc)?; // Same type serializes to JSON

| Component | Description |
|---|---|
Text |
Styled text (bold, center, invert, size 0–3, optional font: "ibm") |
Header |
Pre-styled centered bold header |
Banner |
Framed text with box-drawing borders, auto-sizing (optional font: "ibm") |
LineItem |
Left name + right price (e.g., "Coffee" ... "$4.50") |
Total |
Right-aligned total line |
Divider |
Horizontal line (dashed, solid, double, equals) |
Spacer |
Vertical space in mm, lines, or raw units |
Columns |
Two-column layout (left + right) |
Table |
Table with box-drawing borders, headers, per-column alignment |
Markdown |
Rich text from Markdown (headings, bold, lists) |
Image |
Image from URL (downloaded, cached, dithered, auto-centered) |
Pattern |
Generative art pattern with params |
Canvas |
Absolute-positioned raster compositing with blend modes |
QrCode, Pdf417, Barcode |
1D and 2D barcodes |
NvLogo |
Logo from printer's flash memory |
Thermal printers are binary (black or white), so grayscale images need dithering. Estrella implements four algorithms:
| Algorithm | Characteristics |
|---|---|
| Floyd-Steinberg | Classic error diffusion. Smooth gradients, organic look. Default for photos. |
| Atkinson | Bill Atkinson's Mac algorithm. Higher contrast, loses 25% of error intentionally. |
| Jarvis | Spreads error over 12 neighbors. Smoothest gradients, slightly slower. |
| Bayer | Ordered 8x8 matrix. Fast, deterministic, halftone pattern. Best for patterns. |
| Floyd-Steinberg | Atkinson | Jarvis | Bayer |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Procedural patterns for artistic prints and printer calibration. Each pattern has randomizable parameters.
![]() |
![]() |
![]() |
|---|---|---|
| Ripple | Waves | Plasma |
More patterns
Op Art
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Riley | Vasarely | Scintillate | Moire |
Organic
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Topography | Rings | Flowfield | Mycelium |
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Erosion | Crystal | Reaction Diffusion | Voronoi |
Glitch
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Glitch | Corrupt Barcode | Databend | Scanline Tear |
Generative
![]() |
![]() |
![]() |
|---|---|---|
| Attractor | Automata | Estrella |
Textures
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Crosshatch | Stipple | Woodgrain | Weave |
Calibration
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Calibration | Microfeed | Density | Jitter |
![]() |
|---|
| Overburn |
Blend multiple patterns with DJ-style crossfade transitions:
estrella weave ripple plasma waves --length 200mm --crossfade 30mm

The JSON API uses the same Document type as the Rust API — the component structs are all Serialize + Deserialize, so JSON documents map directly to Rust types with zero conversion. Useful for automations (e.g. Home Assistant daily briefings).
curl -X POST http://localhost:8080/api/json/print \
-H 'Content-Type: application/json' \
-d '{
"document": [
{"type": "banner", "content": "GOOD MORNING"},
{"type": "text", "content": "Monday, January 27", "center": true, "size": 0},
{"type": "divider", "style": "double"},
{"type": "text", "content": " WEATHER ", "bold": true, "invert": true},
{"type": "columns", "left": "Now", "right": "6°C Cloudy"},
{"type": "columns", "left": "High / Low", "right": "11°C / 3°C"},
{"type": "divider"},
{"type": "text", "content": " CALENDAR ", "bold": true, "invert": true},
{"type": "columns", "left": "9:00", "right": "Standup"},
{"type": "columns", "left": "11:30", "right": "Dentist"},
{"type": "divider"},
{"type": "qr_code", "data": "https://calendar.google.com"}
],
"cut": true
}'
The web UI includes a JSON API tab with a live preview editor and a sample daily briefing template.
Canvas components support absolute-positioned compositing with blend modes:
{
"type": "canvas",
"height": 100,
"elements": [
{"type": "pattern", "name": "estrella", "height": 80, "position": {"x": -43, "y": 0}, "blend_mode": "add"},
{"type": "text", "content": "Hello World", "center": true, "position": {"x": 7, "y": 16}, "blend_mode": "add"},
{"type": "total", "amount": 0, "position": {"x": 0, "y": 34}, "blend_mode": "add"}
]
}
Elements without position stack top-to-bottom (flow mode). Dithering defaults to "auto" — Atkinson when continuous-tone content is detected, none otherwise.
Endpoints:
- POST /api/json/preview — returns a PNG preview
- POST /api/json/print — sends to printer
Full component reference
Each component in the "document" array has a "type" field and type-specific properties:
| Type | Required | Optional (defaults) |
|---|---|---|
text |
content |
bold, underline, upperline, invert, upside_down, reduced (false); smoothing (null/auto); align ("left"), center, right (false); size (1, default Font A — 0=Font B, 2=double, 3=triple, or [h,w]); scale (null); double_width, double_height (false); inline (false); font (null — set "ibm" for IBM Plex Sans) |
header |
content |
variant: "normal" (2x2 centered bold) or "small" (1x1) |
banner |
content |
size (3, max expansion 0–3, auto-cascades width); border: "single"/"double"/"heavy"/"shade"/"shadow"; bold (true); padding (1); font (null — set "ibm" for IBM Plex Sans) |
line_item |
name, price |
width (48) |
total |
amount |
label ("TOTAL:"), bold (true), double_width (false), align ("right") |
divider |
— | style: "dashed" / "solid" / "double" / "equals"; width (48) |
spacer |
one of: mm, lines, units |
— |
blank_line |
— | — |
columns |
left, right |
width (48), bold, underline, invert (false) |
table |
rows |
headers (null), border: "single"/"double"/"mixed"/"heavy"/"shade" (default: "single"); align ([] — per-column: "left"/"center"/"right"); row_separator (false); width (48) |
markdown |
content |
show_urls (false) |
qr_code |
data |
cell_size (4), error_level ("M"), align ("center") |
pdf417 |
data |
module_width (3), ecc_level (2), align ("center") |
barcode |
format, data |
height (80); format: "code128" / "code39" / "ean13" / "upca" / "itf" |
image |
url |
dither ("floyd-steinberg"), width (576), height (null), align ("center" — also "left", "right"; only affects images narrower than paper) |
pattern |
name |
height (500), params ({}), dither ("bayer") |
canvas |
elements |
height (auto), width (576), dither ("auto" — detects continuous-tone content); each element: position ({x, y}), blend_mode ("normal"), opacity (1.0) + any component fields |
nv_logo |
key |
center (false), scale (1), scale_x (1), scale_y (1) |
Text size controls both font selection and character expansion using a 1-indexed model:
| Size | Font | Expansion | Chars/line | Description |
|---|---|---|---|---|
0 |
B (9×24) | none | 64 | Small text |
1 |
A (12×24) | none | 48 | Normal (default) |
2 |
A | 2× | 24 | Double |
3 |
A | 3× | 16 | Triple |
Pass a single number for uniform scaling ("size": 2 = double height and width), or an [h, w] array for independent control ("size": [3, 1] = triple height, normal width, 48 chars/line).
The banner component uses the same sizing model but auto-selects the largest width that fits. Given "size": 3, it tries widths 3→2→1→Font B until the content fits inside the box-drawing frame.
cut at the top level defaults to true. Set to false to suppress the paper cut.
Document components emit an intermediate representation (IR), which gets optimized before generating StarPRNT bytes:
``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ COMPILATION PIPELINE │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────┐ │ │ │ Document │ │ IR │ │ Optimizer │ │ Bytes │ │ │ │ │ │ │ │ │ │ │ │ │ │ Text │ │ Op::Init │ │ 4 passes │ │ ESC @ │ │ │ │ LineItem │ ───► │ Op::Text │ ───► │ that remove│ ───► │ ... │ │ │ │ QrCode │ emit │
$ claude mcp add estrella \
-- python -m otcore.mcp_server <graph>