Files
labelprint/app.rb
T
randy 5c06f725f0
deploy / deploy (push) Successful in 9s
Address Brother QL printer by URL path instead of session cookie
Drops /brother/select and the session-based "active printer" state:
the printer key now lives in the path (/brother/<printer>/*), so the
proxy is stateless and any path under it -- including brother_ql_web's
API, not just its UI -- is reachable without a prior "activation" step.
2026-09-12 23:18:08 +02:00

203 lines
6.2 KiB
Ruby

# /usr/bin/env ruby
require 'sinatra'
require "sinatra/reloader"
require 'tmpdir'
require 'net/http'
set :protection, :except => :frame_options
set :environment, :production
set :bind, "::"
set :port, 80
if !File.exist?('config.json') || !File.readable?('config.json')
raise RuntimeException, 'Could not read config.json'
end
# Direct libusb bulk transfer (bypassing the kernel usblp driver, matching how
# brother_ql_web addresses its printers) times out on this Pi 3B's USB
# controller -- back to the kernel driver via a stable udev symlink instead.
PRINTER_DEVICE = '/dev/usb/lp_datamax' # see /etc/udev/rules.d/99-datamax.rules
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')
BROTHER_PRINTERS = {
'ql560' => { port: 8560, label: 'Brother QL-560' },
'ql700' => { port: 8700, label: 'Brother QL-700' },
'ql1100' => { port: 9100, label: 'Brother QL-1100' }
}.freeze
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
def print_to_datamax(data)
File.open(PRINTER_DEVICE, 'wb') { |f| f.write(data) }
end
# Transparently reverse-proxies the current request to one of the
# brother_ql_web instances, so all three printers (web UI and API alike)
# are reachable through this single sinatra port instead of their own
# dedicated ports.
def proxy_to_brother(port, prefix, path)
uri = URI("http://127.0.0.1:#{port}#{path}")
uri.query = request.query_string unless request.query_string.empty?
proxied = Net::HTTP.const_get(request.request_method.capitalize).new(uri)
request.env.each do |key, value|
next unless key.start_with?('HTTP_') && key != 'HTTP_HOST'
header = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
proxied[header] = value
end
proxied['Content-Type'] = request.content_type if request.content_type
proxied['X-Forwarded-Prefix'] = prefix
proxied['X-Forwarded-Host'] = request.host
proxied.body = request.body.read unless request.request_method == 'GET'
res = Net::HTTP.start(uri.host, uri.port) { |http| http.request(proxied) }
body = res.body || ''
if res['Content-Type']&.include?('text/html')
body = body.sub(/(<body[^>]*>)/i) { "#{Regexp.last_match(1)}\n#{erb(:_navbar, layout: false)}" }
end
headers = {}
res.each_header do |key, value|
next if %w[transfer-encoding connection content-length].include?(key.downcase)
headers[key] = value
end
[res.code.to_i, headers, [body]]
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)
print_to_datamax(zpl)
flash "PDF: Upload successful (#{filename})", "success"
rescue => e
flash "PDF: Failed to print (#{e.message})", "danger"
end
when ".zpl"
print_to_datamax(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)
print_to_datamax(zpl)
flash 'Test label sent to printer', 'success'
rescue => e
flash "Test print failed (#{e.message})", 'danger'
end
redirect '/upload'
end
get '/brother' do
@printers = BROTHER_PRINTERS
erb :brother_picker
end
%w[get post put delete].each do |verb|
send(verb, '/brother/:printer/*') do
printer = BROTHER_PRINTERS[params[:printer]]
halt 404, 'unknown printer' unless printer
proxy_to_brother(printer[:port], "/brother/#{params[:printer]}", '/' + params[:splat].first)
end
end