# /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.auto_detach_kernel_driver = true # usblp kernel driver otherwise holds the interface 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(/(]*>)/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