Markdown-to-thermal-print system for ESC/POS receipt printers. Renders text as images with custom fonts, so you get full typographic control on cheap thermal paper.
Built for the BORN4SHIP V330M (Xprinter XP-V330M, 80mm) but works with any ESC/POS printer over USB.
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txtmacOS also needs libusb: brew install libusb
# Print a markdown file
./print.sh md --file myfile.md
# Pick a font style
./print.sh md --file myfile.md --style helvetica
./print.sh md --file myfile.md --style acidic
# Print a test page
./print.sh test
# Print a simple message
./print.sh message "Hello World" --title "NOTICE"Print photos and graphics with three dither modes:
| Mode | Look | Best for |
|---|---|---|
floyd (default) |
Smooth, organic | Photos, portraits, detailed artwork |
bayer |
Crosshatch grid | Stylized/retro look, pairs well with blur |
halftone |
Dot grid (newspaper) | Bold graphics, high-contrast images |
# Print an image (Floyd-Steinberg, default)
./print.sh image photo.jpg
# Bayer dithering with gaussian blur
./print.sh image photo.jpg --mode bayer --blur 10
# Halftone with custom dot size
./print.sh image photo.jpg --mode halftone --dot 4
# Adjust contrast/brightness/sharpness
./print.sh image photo.jpg --contrast 1.5 --brightness 1.1 --sharpness 1.4Slice an image into strips for wider or taller prints — tape them together for posters:
# 4 vertical strips (left to right) — tape side by side
./print.sh slice photo.jpg 4 --direction vertical --mode bayer --blur 10
# 3 horizontal strips (top to bottom)
./print.sh slice photo.jpg 3 --direction horizontal --mode floydcurl -X POST http://localhost:9100/print/image \
-F "file=@photo.jpg" -F "mode=bayer" -F "blur=10"AI-powered portrait-to-sculpture pipeline. Takes a photo, transforms it into a translucent wax bust aesthetic via Gemini Flash Image (through n8n), then crops at 4 face-landmark zoom levels with bayer dithering.
Requires OPENROUTER_API_KEY env var and an active n8n workflow.
# Quick run (dummy mode, saves previews)
export OPENROUTER_API_KEY="your-key-here"
./run_portrait.sh photo.jpg
# Full CLI with options
./print.sh portrait photo.jpg --skip-selection
./print.sh portrait photo1.jpg photo2.jpg photo3.jpg # AI picks best
./print.sh portrait photo.jpg --skip-transform # print original with dithering
./print.sh portrait photo.jpg --blur 15 --mode floyd
# Dummy mode — save previews without printing
./print.sh --dummy portrait photo.jpg --skip-selectionOutput (4 zoom levels, computed from mediapipe face landmarks):
zoom_0— shoulders to hairline (full portrait)zoom_1— chin to forehead, outer-eye widthzoom_2— inter-pupillary width, nose-bridge heightzoom_3— narrow vertical strip through face center
The style prompt lives in config.yaml under portrait.style_prompt — edit it directly and re-run.
# Full pipeline (upload photos, transform + print)
curl -X POST http://localhost:9100/portrait/capture \
-F "file=@photo.jpg"
# Transform only (returns PNG, no printing)
curl -X POST http://localhost:9100/portrait/transform \
-F "file=@photo.jpg" --output statue.pngThree built-in styles, configured in config.yaml:
| Style | Font | Character |
|---|---|---|
dictionary (default) |
Burra Thin/Bold | Geometric, all-caps art font |
helvetica |
Helvetica Neue Light/Bold | Clean, minimal, classic |
acidic |
Acidic | Big, raw, hard-wraps mid-word |
# Heading 1 → bold, large
## Heading 2 → bold, body size
**bold** → bold inline
*italic* → underlined inline
~~strikethrough~~ → line through text
`code` → inverted box
- list item → indented with dash
> blockquote → indented with bar
--- → dotted separator
Indentation from the source .md file is preserved as visual indent on paper.
For automated workflows — any program can send print jobs over HTTP:
./print.sh # or: python print_server.py# Print markdown
curl -X POST http://localhost:9100/print/markdown \
-H "Content-Type: application/json" \
-d '{"text": "# Hello\n\nWorld", "style": "helvetica"}'
# Print a receipt
curl -X POST http://localhost:9100/print/receipt \
-H "Content-Type: application/json" \
-d '{"items":[{"name":"Coffee","qty":2,"price":5.0}]}'
# Print a dictionary entry
curl -X POST http://localhost:9100/print/dictionary \
-H "Content-Type: application/json" \
-d '{"word":"Ephemeral","definition":"Lasting for a very short time."}'Add a section to config.yaml:
mystyle:
font_word: "fonts/MyFont-Bold.ttf"
font_body: "fonts/MyFont-Regular.ttf"
font_bold: "fonts/MyFont-Bold.ttf"
font_cite: "fonts/MyFont-Light.ttf"
font_date: "fonts/MyFont-Light.ttf"
size_word: 32
size_body: 20
size_cite: 18
size_date: 16
line_spacing: 1.4
gap_after_word: 30
gap_before_cite: 20
margin: 20
paper_px: 576
# hard_wrap: true # uncomment for character-level line breaksFor .ttc collection files (like system fonts), add _index fields:
font_body: "/System/Library/Fonts/HelveticaNeue.ttc"
font_body_index: 7 # Light weightThen use it: ./print.sh md --file text.md --style mystyle
print.sh Wrapper script (no venv activation needed)
print_cli.py CLI tool
print_server.py HTTP server for automated print jobs
printer_core.py Printer connection + text formatting helpers
templates.py Print templates (receipt, dictionary, markdown, etc.)
md_renderer.py Markdown → image renderer
image_printer.py Image dithering engine (floyd, bayer, halftone + blur)
image_slicer.py Vertical/horizontal strip slicing for poster prints
portrait_pipeline.py Portrait-to-statue pipeline (n8n + mediapipe)
run_portrait.sh Quick-run script for portrait pipeline
config.yaml Printer config + font style definitions + portrait prompt
fonts/ Font files
Tested on Raspberry Pi 3 with Debian Bookworm. The Pi runs the print server headlessly on the network — any device on the same WiFi can send print jobs.
# Install system dependencies
sudo apt update && sudo apt install -y python3-venv python3-pip git libusb-1.0-0-dev
# Clone and set up
git clone https://github.com/schaferjart/POS-thermal-printer.git
cd POS-thermal-printer
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txtThe printer needs root access by default. Either run with sudo:
sudo ./venv/bin/python3 print_server.pyOr add a udev rule for unprivileged access:
sudo tee /etc/udev/rules.d/99-escpos.rules <<< \
'SUBSYSTEM=="usb", ATTRS{idVendor}=="1fc9", ATTRS{idProduct}=="2016", MODE="0666"'
sudo udevadm control --reload
# Unplug and replug the printerThe Pi 3's built-in WiFi works with eduroam but needs manual wpa_supplicant configuration since it uses WPA2-Enterprise (PEAP/MSCHAPv2).
1. Disable any access point mode (if previously configured):
sudo systemctl stop hostapd
sudo systemctl disable hostapd2. Remove any static IP on wlan0 — check /etc/dhcpcd.conf for lines like interface wlan0, static ip_address=..., or nohook wpa_supplicant and delete them.
3. Configure wpa_supplicant (sudo nano /etc/wpa_supplicant/wpa_supplicant.conf):
ctrl_interface=DIR=/var/run/wpa_supplicant
update_config=1
country=CH
network={
ssid="eduroam"
key_mgmt=WPA-EAP
eap=PEAP
phase2="auth=MSCHAPV2"
identity="user@student-net.ethz.ch"
password="your_password"
priority=20
}
network={
ssid="HomeWiFi"
psk="home_password"
key_mgmt=WPA-PSK
priority=5
}
Multiple network blocks are supported — the Pi connects to whichever is available, preferring higher priority values.
4. Start wpa_supplicant manually (the systemd service doesn't attach to wlan0 by default on Bookworm):
sudo killall wpa_supplicant
sudo wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf
sudo dhcpcd wlan05. Fix DNS (eduroam may not set nameservers correctly):
echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf
sudo chattr +i /etc/resolv.conf # lock so dhcpcd can't overwriteCreate a systemd service so the print server starts automatically when the Pi powers on:
sudo nano /etc/systemd/system/pos-printer.service[Unit]
Description=POS Thermal Print Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/stoffel/POS-thermal-printer
ExecStartPre=/bin/bash -c 'killall wpa_supplicant 2>/dev/null; wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf; dhcpcd wlan0; sleep 5'
ExecStart=/home/stoffel/POS-thermal-printer/venv/bin/python3 print_server.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable pos-printer.service
sudo systemctl start pos-printer.serviceThe Pi will now connect to WiFi and start the print server on every boot. Check status with sudo systemctl status pos-printer.service.
So you don't need to look up the Pi's IP every time:
sudo apt install -y avahi-daemon
sudo systemctl enable avahi-daemonNow reach the Pi at raspberrypi.local from any device on the same network:
curl http://raspberrypi.local:9100/healthmDNS (raspberrypi.local) works on home networks but not on eduroam (blocks multicast). To find the Pi on eduroam, scan for its MAC address (Pi 3 MACs start with b8:27):
arp -a | grep -i "b8:27"Once the server is running, any device on the same network can print:
# From any machine on the network
curl -X POST http://<pi-ip>:9100/print/markdown \
-H "Content-Type: application/json" \
-d '{"text": "# Hello\n\nPrinted from the network!", "show_date": false}'
# Health check
curl http://<pi-ip>:9100/health