Initial commit: ZPL-native PDF printing, drop CUPS dependency

This commit is contained in:
randy committed 2026-09-12 00:22:35 +02:00
commit 2ea45b5f21
15 files changed
+626

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.session_secret
public/files/*
!public/files/.gitkeep
+10
View File
@@ -0,0 +1,10 @@
source 'https://rubygems.org'
gem 'sinatra'
gem 'sinatra-contrib'
gem 'puma'
gem 'rqrcode'
gem 'logger'
gem 'ostruct'
gem 'rackup', "~> 2.1"
+56
View File
@@ -0,0 +1,56 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.3.0)
chunky_png (1.4.0)
logger (1.7.0)
multi_json (1.21.1)
mustermann (3.1.1)
nio4r (2.7.5)
ostruct (0.6.3)
puma (8.0.2)
nio4r (~> 2.0)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rackup (2.3.1)
rack (>= 3)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sinatra-contrib (4.2.1)
multi_json (>= 0.0.2)
mustermann (~> 3.0)
rack-protection (= 4.2.1)
sinatra (= 4.2.1)
tilt (~> 2.0)
tilt (2.7.0)
PLATFORMS
aarch64-linux
armv7l-linux-eabihf
DEPENDENCIES
logger
ostruct
puma
rackup (~> 2.1)
rqrcode
sinatra
sinatra-contrib
BUNDLED WITH
4.0.14
+138
View File
@@ -0,0 +1,138 @@
# /usr/bin/env ruby
require 'sinatra'
require "sinatra/reloader"
require 'tmpdir'
set :protection, :except => :frame_options
set :environment, :production
set :bind, "::"
set :port, 4567
if !File.exist?('config.json') || !File.readable?('config.json')
raise RuntimeException, 'Could not read config.json'
end
PRINTER_DEVICE = '/dev/usb/lp0'
PRINTER_DPI = 203 # DatamaxONeil E4204B Mark III native head resolution
PRINTER_DARKNESS = 8 # lower than factory default to stop thermal dot-gain from fusing barcode bars
TEST_LABEL_PDF = File.join(File.dirname(__FILE__), 'test-label.pdf')
configure do
enable :static
set :session_secret, File.read(File.join(File.dirname(__FILE__), '.session_secret')).strip
enable :sessions
set :views, File.join(File.dirname(__FILE__), 'views')
set :public_folder, File.join(File.dirname(__FILE__), 'public')
set :files, File.join(settings.public_folder, 'files')
set :unallowed_paths, ['.', '..']
end
configure :production do
enable :reloader
end
helpers do
def flash(message = '', level)
case level
when "success"
session[:alert_success] = message
when "danger"
session[:alert_danger] = message
when "warning"
session[:alert_warning] = message
end
end
# Renders a PDF to ZPL ^GFA graphic labels at the printer's native DPI,
# bypassing CUPS/Gutenprint's raster pipeline which blurs text and 1px-shifts
# output compared to native ZPL rendering.
def pdf_to_zpl(pdf_path, dpi: PRINTER_DPI)
Dir.mktmpdir do |dir|
pattern = File.join(dir, 'page-%03d.pbm')
ok = system('gs', '-q', '-dNOPAUSE', '-dBATCH', '-dSAFER',
'-sDEVICE=pbmraw', "-r#{dpi}",
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
"-sOutputFile=#{pattern}", pdf_path)
raise 'ghostscript rendering failed' unless ok
Dir.glob(File.join(dir, 'page-*.pbm')).sort.map { |f| pbm_to_zpl_label(f) }.join
end
end
def pbm_to_zpl_label(pbm_path)
bytes = File.binread(pbm_path)
header = /\AP4\s+(?:#[^\n]*\n\s*)*(\d+)\s+(\d+)\s/.match(bytes)
raise "unreadable PBM header in #{pbm_path}" unless header
width, height = header[1].to_i, header[2].to_i
bytes_per_row = (width + 7) / 8
total_bytes = bytes_per_row * height
body = bytes.byteslice(header.end(0), total_bytes)
hexdata = body.unpack1('H*').upcase
"^XA\n^MD#{PRINTER_DARKNESS}\n^PW#{width}\n^LL#{height}\n^FO0,0^GFA,#{total_bytes},#{total_bytes},#{bytes_per_row},#{hexdata}\n^XZ\n"
end
end
before do
@alert_success = session.delete(:alert_success)
@alert_danger = session.delete(:alert_danger)
@alert_warning = session.delete(:alert_warning)
end
get '/' do
erb :index
end
get '/upload' do
erb :upload
end
post '/upload' do
if params[:file]
tempfile = params[:file][:tempfile]
filename = params[:file][:filename]
filetype = File.extname filename
filepath = File.join(settings.files, filename)
File.open(filepath, 'wb') do |f|
f.write tempfile.read
end
case filetype
when ".pdf"
begin
zpl = pdf_to_zpl(filepath)
File.open(PRINTER_DEVICE, 'wb') { |f| f.write(zpl) }
flash "PDF: Upload successful (#{filename})", "success"
rescue => e
flash "PDF: Failed to print (#{e.message})", "danger"
end
when ".zpl"
File.open(PRINTER_DEVICE, 'wb') { |f| f.write(File.binread(filepath)) }
flash "ZPL: Upload successful (#{filename})", "success"
else
flash "#{filetype} is not supported. Only .pdf and .zpl!", "false"
end
File.delete(filepath) if File.exist?(filepath)
else
flash 'You have to choose a file', "warning"
end
redirect '/upload'
end
post '/test-print' do
begin
zpl = pdf_to_zpl(TEST_LABEL_PDF)
File.open(PRINTER_DEVICE, 'wb') { |f| f.write(zpl) }
flash 'Test label sent to printer', 'success'
rescue => e
flash "Test print failed (#{e.message})", 'danger'
end
redirect '/upload'
end
+32
View File
@@ -0,0 +1,32 @@
{
"printers":[
{
"uri": "file:///dev/usb/lp0",
"model": "E4204B",
"dimensions": "100x150",
"name": "Datamax-ONeil_E4204B_Mark_III",
"driver": "zpl"
},
{
"uri": "file:///dev/usb/lp1",
"model": "QL-700",
"dimensions": "62",
"name": "Brother QL-700",
"driver": "brother_ql"
},
{
"uri": "file:///dev/usb/lp2",
"model": "QL-1100",
"dimensions": "102",
"name": "Brother QL-1100",
"driver": "brother_ql"
},
{
"uri": "file:///dev/usb/lp3",
"model": "QL-560",
"dimensions": "12+19",
"name": "Brother QL-560",
"driver": "brother_ql"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
require 'bundler'
Bundler.require
require './app'
run Sinatra::Application
+127
View File
@@ -0,0 +1,127 @@
# gemset.gem.gems generated gem export file. Note that any env variable settings will be missing. Append these after using a ';' field separator
error_highlight -v0.3.0
did_you_mean -v1.6.1
abbrev -v0.1.0
awesome_print -v1.9.2
base64 -v0.1.1
benchmark -v0.2.0
bigdecimal -v3.1.2
bigdecimal -v3.1.1
bundler -v2.3.10
bundler -v2.3.6
cgi -v0.3.2
cgi -v0.3.1
chunky_png -v1.4.0
csv -v3.2.2
cupsffi -v0.1.9
date -v3.2.2
delegate -v0.2.0
digest -v3.1.0
domain_name -v0.5.20190701
drb -v2.1.0
english -v0.7.1
erb -v2.2.3
etc -v1.3.0
fcntl -v1.0.1
ffi -v1.15.5
fiddle -v1.1.0
fileutils -v1.6.0
find -v0.1.1
forwardable -v1.3.2
getoptlong -v0.1.1
http-accept -v2.1.1
http-accept -v1.7.0
http-cookie -v1.0.4
io-console -v0.5.11
io-console -v0.5.10
io-nonblock -v0.1.0
io-wait -v0.2.1
ipaddr -v1.2.4
ipaddr -v1.2.3
irb -v1.4.1
json -v2.6.1
logger -v1.5.1
logger -v1.5.0
mime-types -v3.4.1
mime-types-data -v3.2022.0105
multi_json -v1.15.0
mustermann -v2.0.2
mustermann -v1.1.1
mutex_m -v0.1.1
net-http -v0.2.0
net-imap -v0.2.3
net-protocol -v0.1.2
netrc -v0.11.0
nio4r -v2.5.8
nkf -v0.1.1
observer -v0.1.1
open-uri -v0.2.0
open3 -v0.1.1
openssl -v3.0.0
optparse -v0.2.0
ostruct -v0.5.4
ostruct -v0.5.3
ostruct -v0.5.2
pathname -v0.2.0
pp -v0.3.0
prettyprint -v0.1.1
pstore -v0.1.1
psych -v4.0.3
puma -v5.6.5
puma -v5.6.2
puma -v5.6.1
racc -v1.6.0
rack -v2.2.4
rack -v2.2.3
rack-protection -v2.2.2
rack-protection -v2.2.0
rack-protection -v2.1.0
rbs -v2.2.2
rbs -v2.1.0
rdoc -v6.4.0
readline -v0.0.3
readline-ext -v0.1.4
reline -v0.3.1
reline -v0.3.0
resolv -v0.2.1
resolv-replace -v0.1.0
rest-client -v2.1.0
rinda -v0.1.1
rqrcode -v2.1.2
rqrcode -v2.1.1
rqrcode -v2.1.0
rqrcode_core -v1.2.0
ruby2_keywords -v0.0.5
rubygems-update -v3.3.10
rubygems-update -v3.3.6
securerandom -v0.2.0
securerandom -v0.1.1
set -v1.0.2
shellwords -v0.1.0
sinatra -v2.2.2
sinatra -v2.2.0
sinatra -v2.1.0
sinatra-contrib -v2.2.2
sinatra-contrib -v2.2.0
sinatra-contrib -v2.1.0
singleton -v0.1.1
stringio -v3.0.1
strscan -v3.0.1
syslog -v0.1.0
tempfile -v0.1.2
tilt -v2.0.11
tilt -v2.0.10
time -v0.2.0
timeout -v0.2.0
tmpdir -v0.1.2
tsort -v0.1.0
typeprof -v0.21.2
un -v0.2.0
unf -v0.1.4
unf_ext -v0.0.8.1
unf_ext -v0.0.8
uri -v0.11.0
weakref -v0.1.1
yaml -v0.2.0
zlib -v2.1.1
+7
View File
@@ -0,0 +1,7 @@
---
applications:
- name: bootstrap
memory: 128M
instances: 1
url: bootstrap.${target-base}
path: .
View File
Whitespace-only changes.
BIN
View File
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
require 'tmpdir'
require 'rqrcode'
DPI = 203
SRC_PDF = ARGV[0] || '/home/randy/EPH266757312_adresne_stitky_a6.pdf'
OUT_PS = ARGV[1] || '/tmp/test-label-v2.ps'
# barcode crop region in the source label, in native pixels at 203dpi
BARCODE_BOX = { x0: 388, y0: 222, x1: 772, y1: 322 }
def read_pbm(path)
bytes = File.binread(path)
header = /\AP4\s+(?:#[^\n]*\n\s*)*(\d+)\s+(\d+)\s/.match(bytes)
raise "bad pbm #{path}" unless header
width, height = header[1].to_i, header[2].to_i
bytes_per_row = (width + 7) / 8
body = bytes.byteslice(header.end(0), bytes_per_row * height)
{ width: width, height: height, bytes_per_row: bytes_per_row, body: body }
end
def pbm_bit(pbm, x, y)
byte = pbm[:body].getbyte(y * pbm[:bytes_per_row] + (x / 8))
(byte >> (7 - (x % 8))) & 1
end
# Packs a [x,y] => 0/1 (1 = black) pixel getter into row-padded, bit-inverted
# (PostScript DeviceGray 1bpc: 0=black) bytes ready for the `image` operator.
def pack_for_ps_image(width, height)
bytes_per_row = (width + 7) / 8
rows = Array.new(height) { "\x00".b * bytes_per_row }
height.times do |y|
row = rows[y].dup.b
width.times do |x|
next unless yield(x, y) == 1
byte_index = x / 8
bit = 0x80 >> (x % 8)
row.setbyte(byte_index, row.getbyte(byte_index) | bit)
end
rows[y] = row
end
packed = rows.join
inverted = packed.bytes.map { |b| (~b) & 0xFF }.pack('C*')
{ width: width, height: height, hex: inverted.unpack1('H*').upcase }
end
def ps_image_block(x_pt, y_pt, w_pt, h_pt, img)
<<~PS
gsave
#{x_pt} #{y_pt} translate
#{w_pt} #{h_pt} scale
#{img[:width]} #{img[:height]} 1
[#{img[:width]} 0 0 -#{img[:height]} 0 #{img[:height]}]
{ <#{img[:hex]}> } image
grestore
PS
end
Dir.mktmpdir do |dir|
pbm_path = File.join(dir, 'label.pbm')
system('gs', '-q', '-dNOPAUSE', '-dBATCH', '-dSAFER',
'-sDEVICE=pbmraw', "-r#{DPI}", '-dFirstPage=1', '-dLastPage=1',
"-sOutputFile=#{pbm_path}", SRC_PDF) or raise 'gs failed'
pbm = read_pbm(pbm_path)
bw = BARCODE_BOX[:x1] - BARCODE_BOX[:x0]
bh = BARCODE_BOX[:y1] - BARCODE_BOX[:y0]
barcode_img = pack_for_ps_image(bw, bh) do |x, y|
pbm_bit(pbm, BARCODE_BOX[:x0] + x, BARCODE_BOX[:y0] + y)
end
qr = RQRCode::QRCode.new('TEST-LABEL-PRINTSERVER')
modules = qr.modules
quiet = 4
qsize = modules.size + quiet * 2
qr_img = pack_for_ps_image(qsize, qsize) do |x, y|
mx, my = x - quiet, y - quiet
next 0 if mx.negative? || my.negative? || mx >= modules.size || my >= modules.size
modules[my][mx] ? 1 : 0
end
# physical sizes on the A6 (298x420pt) page
barcode_pt_w = 220
barcode_pt_h = barcode_pt_w * bh / bw
qr_pt = 100
ps = <<~PS
%!PS
<< /PageSize [298 420] >> setpagedevice
1 setlinewidth
10 10 278 400 rectstroke
/Helvetica-Bold findfont 24 scalefont setfont
30 380 moveto (TEST LABEL) show
/Helvetica findfont 9 scalefont setfont
30 362 moveto (Generic print-quality test page - safe to reprint anytime) show
/Helvetica-Bold findfont 13 scalefont setfont
30 330 moveto (Odosielatel: Test Sender, BA) show
30 312 moveto (Adresat: Test Recipient, KE) show
/Helvetica findfont 8 scalefont setfont
30 292 moveto (Fine print 8pt: quick brown fox 0123456789) show
#{ps_image_block(30, 210, barcode_pt_w, barcode_pt_h, barcode_img)}
/Helvetica findfont 8 scalefont setfont
30 198 moveto (1D: reused known-good Code128 from a real courier label) show
#{ps_image_block(30, 60, qr_pt, qr_pt, qr_img)}
/Helvetica findfont 8 scalefont setfont
140 100 moveto (QR: TEST-LABEL-PRINTSERVER) show
140 88 moveto (rendered natively via rqrcode) show
showpage
PS
File.write(OUT_PS, ps)
puts "wrote #{OUT_PS}"
end
+61
View File
@@ -0,0 +1,61 @@
<div class="card" >
<div class="card-body">
<h5 class="card-title">Brother Label Designer</h5>
<p class="card-text">A web service to print labels on Brother QL label printers.</p>
<a href="http://rpi-printserver.local:8560/labeldesigner" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">QL-560</a>
<a href="http://rpi-printserver.local:8700/labeldesigner" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">QL-700</a>
<a href="http://rpi-printserver.local:9100/labeldesigner" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">QL-1100</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Sticker Factory</h5>
<p class="card-text">Print images, text and cats on a Brother QL label printer</p>
<a href="http://rpi-printserver.local:8989" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Go</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Cups</h5>
<p class="card-text">The standards-based, open source printing system developed by Apple for iOS®, iPadOS®, and macOS®. CUPS uses IPP Everywhere™ to support printing to local and network printers.</p>
<a href="https://rpi-printserver.local:631/admin" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Go</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Cockpit</h5>
<p class="card-text">Cockpit is a web-based graphical interface for servers, intended for everyone</p>
<a href="https://rpi-printserver.local:9090" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Go</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">ZPL Viewer</h5>
<p class="card-text">An useful tool for labels in ZPL format.</p>
<a href="https://labelary.com/viewer.html" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Go</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Creality Sonic Pad</h5>
<p class="card-text">3D Printer</p>
<a href="http://spad-9088.local" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Fluidd</a>
<a href="http://spad-9088.local:8819" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer">Mainsail</a>
<a href="http://spad-9088.local:7125" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer">Moonraker</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Bigtreetech Pad 7 CNC</h5>
<p class="card-text">CNC</p>
<a href="http://btt-pad7-cnc.local" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Mainsail</a>
<a href="http://btt-pad7-cnc.local:7125" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer">Moonraker</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Bigtreetech Pad 7 CAN</h5>
<p class="card-text">CAN</p>
<a href="http://rpi-btt-pad7-can.local" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Mainsail</a>
</div>
</div>
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<title>Print server</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link href="data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAgAAAAAAAAAAAAAAAEAAAAAAAAAD9/f0AAAAAAOzs7ADl5eUA/Pz8ACsZBgD+/v4AAwMDAPv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEWZmZmZmIxERZmZmZmZmERFmZmZmZmYREWZmZoZmZhERZmYUVmZmERFmYREWZmYREWYRERFmZhERZhYREXZmERFmZmYRZmYREWZmZhFmZhERZmZmERFmERFmZmYHEWYREWZmZmZmZhERZmZmZmZmERFmZmZmZmYREWZmZmZmZhHAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAA" rel="icon" type="image/x-icon" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<link href="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">Print server</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/upload">Shipping labels</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="row">
<% if @alert_success then %>
<div class="alert alert-success"><%=@alert_success%></div>
<% end %>
<% if @alert_danger then %>
<div class="alert alert-danger"><%=@alert_danger%></div>
<% end %>
<% if @alert_warning then %>
<div class="alert alert-warning"><%=@alert_warning%></div>
<% end %>
<%= yield %>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone-min.js"></script>
<script>
Dropzone.options.UploadForm = {
paramFile: "file",
accept: function(file, done) {
done ();
}
};
</script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<form action='/login/attempt' method='post' class="form-inline well">
<input type="text" name='username' placeholder="username" autofocus>
<input class="btn primary" type='submit' value='log in'>
</form>
+11
View File
@@ -0,0 +1,11 @@
<form action="/upload" class="dropzone" id="upload-form" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="formFile" class="form-label">Upload .pdf or .zpl to print label</label>
<input name="file" class="form-control" type="file" id="formFile" accept=".pdf,.zpl" />
</div>
<button class="btn btn-secondary" type="submit">Upload</button>
</form>
<form action="/test-print" method="POST" class="mt-3">
<button class="btn btn-outline-secondary" type="submit">Print test label</button>
</form>