Files
labelprint/app.rb
T
2026-09-12 16:55:38 +02:00

228 lines
6.8 KiB
Ruby

# /usr/bin/env ruby
require 'sinatra'
require "sinatra/reloader"
require 'tmpdir'
require 'net/http'
require 'libusb'
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
# Addressed the same way as the Brother QL printers (usb://VID:PID/Serial via
# brother_ql_web) instead of a udev symlink -- self-contained, no reliance on
# OS device naming/ordering.
DATAMAX_VENDOR_ID = 0x0b0b
DATAMAX_PRODUCT_ID = 0x106e
DATAMAX_SERIAL = '24624'
DATAMAX_ENDPOINT = 0x02 # bulk OUT, interface 0
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)
usb = LIBUSB::Context.new
device = usb.devices(idVendor: DATAMAX_VENDOR_ID, idProduct: DATAMAX_PRODUCT_ID)
.find { |d| d.serial_number == DATAMAX_SERIAL }
unless device
raise format('Datamax printer not found (USB %04x:%04x/%s)',
DATAMAX_VENDOR_ID, DATAMAX_PRODUCT_ID, DATAMAX_SERIAL)
end
device.open do |handle|
handle.claim_interface(0) do
handle.bulk_transfer(endpoint: DATAMAX_ENDPOINT, dataOut: data)
end
end
end
# Transparently reverse-proxies the current request to one of the
# brother_ql_web instances, so all three printers are reachable through
# this single sinatra port instead of their own dedicated ports.
def proxy_to_brother(port, 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'] = '/brother'
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
@selected = session[:brother_printer]
erb :brother_picker
end
post '/brother/select' do
key = params[:printer]
halt 400, 'unknown printer' unless BROTHER_PRINTERS.key?(key)
session[:brother_printer] = key
redirect '/brother/'
end
%w[get post put delete].each do |verb|
send(verb, '/brother/*') do
key = session[:brother_printer]
redirect '/brother' unless key
proxy_to_brother(BROTHER_PRINTERS[key][:port], '/' + params[:splat].first)
end
end