495 lines
15 KiB
Ruby
495 lines
15 KiB
Ruby
# /usr/bin/env ruby
|
|
require 'sinatra'
|
|
require "sinatra/reloader"
|
|
require 'tmpdir'
|
|
require 'net/http'
|
|
require_relative 'lib/receipt_builder'
|
|
require_relative 'lib/label_repository'
|
|
|
|
set :protection, :except => :frame_options
|
|
set :environment, :production
|
|
# nginx reverse-proxies :80 -> here. Loopback-only and an unprivileged port
|
|
# means no more cap_net_bind_service dance on every RVM ruby upgrade.
|
|
set :bind, "127.0.0.1"
|
|
set :port, 4567
|
|
|
|
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')
|
|
|
|
PRINTER_DEVICE_EPSON = '/dev/usb/lp_epson' # see /etc/udev/rules.d/99-epson.rules
|
|
|
|
LABEL_REPO = LabelRepository.new(File.join(File.dirname(__FILE__), 'data', 'labels'))
|
|
|
|
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
|
|
|
|
# Builds a simple receipt (bold centered title + left-aligned body,
|
|
# optional QR code) so callers don't need to know ESC/POS escape codes.
|
|
def print_receipt(title:, body:, qr: nil)
|
|
receipt = ReceiptBuilder.new
|
|
receipt.align(:center).bold.text("#{title}\n").bold(false) unless title.to_s.empty?
|
|
receipt.align(:left).text("#{body}\n") unless body.to_s.empty?
|
|
unless qr.to_s.empty?
|
|
receipt.align(:center).feed(1).qr(qr).feed(1)
|
|
end
|
|
receipt.feed(3).cut
|
|
receipt.print!(PRINTER_DEVICE_EPSON)
|
|
end
|
|
|
|
# Procedural 64x64 checkerboard (8px squares) -- no image library needed,
|
|
# just to prove GS v 0 raster printing works end to end.
|
|
def checkerboard_bitmap(size_px = 64, block_px = 8)
|
|
width_bytes = size_px / 8
|
|
bytes = "".b
|
|
size_px.times do |y|
|
|
row_block = y / block_px
|
|
width_bytes.times do |xb|
|
|
byte = 0
|
|
8.times do |bit|
|
|
x = xb * 8 + bit
|
|
col_block = x / block_px
|
|
byte |= (1 << (7 - bit)) if (row_block + col_block).even?
|
|
end
|
|
bytes << byte.chr
|
|
end
|
|
end
|
|
bytes
|
|
end
|
|
|
|
# Demonstrates most of what the printer can do: text sizes/styles, EAN-13,
|
|
# QR code, and a raster image -- mirrors /test-print's role for the Datamax.
|
|
def print_epson_test_page
|
|
receipt = ReceiptBuilder.new
|
|
receipt.align(:center).bold.size(:big).text("TM-T20II TEST\n").size(:normal).bold(false)
|
|
receipt.align(:left).text(('-' * 48) + "\n")
|
|
|
|
receipt.section_separator('TEXT STYLES')
|
|
receipt.text("Normal text\n")
|
|
receipt.bold.text("Bold text\n").bold(false)
|
|
receipt.underline.text("Underlined text\n").underline(false)
|
|
receipt.size(:wide).text("Double width\n").size(:normal)
|
|
receipt.size(:high).text("Double height\n").size(:normal)
|
|
receipt.size(:big).text("Double both\n").size(:normal)
|
|
|
|
receipt.section_separator('BARCODE')
|
|
receipt.align(:center).ean13('2012345678902')
|
|
|
|
receipt.section_separator('QR CODE')
|
|
receipt.align(:center).qr('https://git.priesol.net/randy/home-gitops')
|
|
|
|
receipt.section_separator('IMAGE')
|
|
receipt.align(:center).image(64, 64, checkerboard_bitmap)
|
|
|
|
receipt.align(:left).text(('-' * 48) + "\n")
|
|
receipt.text("Printed: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
receipt.feed(3).cut
|
|
receipt.print!(PRINTER_DEVICE_EPSON)
|
|
end
|
|
|
|
ESCPOS_MNEMONICS = {
|
|
'ESC' => "\x1B", 'GS' => "\x1D", 'FS' => "\x1C", 'DLE' => "\x10",
|
|
'LF' => "\x0A", 'CR' => "\x0D", 'HT' => "\x09", 'NUL' => "\x00"
|
|
}.freeze
|
|
|
|
# Parses a line-based mnemonic ESC/POS notation, e.g.:
|
|
# ESC a 0x01 # Align: Center
|
|
# Hello # literal text
|
|
# LF
|
|
# GS k 0x03 12345678 0x00
|
|
# A trailing comment is only a comment when "#" is its own
|
|
# whitespace-separated token -- not just stripped from the first "#"
|
|
# anywhere in the line, which would mangle literal text like "#5 Fix
|
|
# thing" (an issue reference, not a comment). Each remaining token
|
|
# becomes a known control mnemonic, a raw `0xNN` byte, or literal text
|
|
# (CP852-encoded) -- consecutive literal tokens are rejoined with a
|
|
# single space so plain prose still reads correctly, while whitespace
|
|
# around control/byte tokens is dropped since those are just for human
|
|
# readability, not printer output.
|
|
def parse_escpos_notation(text)
|
|
bytes = "".b
|
|
text.each_line do |line|
|
|
prev_was_text = false
|
|
tokens = line.split
|
|
comment_at = tokens.index('#')
|
|
tokens = tokens[0...comment_at] if comment_at
|
|
tokens.each do |token|
|
|
if ESCPOS_MNEMONICS.key?(token)
|
|
bytes << ESCPOS_MNEMONICS[token]
|
|
prev_was_text = false
|
|
elsif token =~ /\A0x([0-9A-Fa-f]{2})\z/
|
|
bytes << [Regexp.last_match(1)].pack('H2')
|
|
prev_was_text = false
|
|
else
|
|
bytes << ' ' if prev_was_text
|
|
bytes << token.encode('CP852', invalid: :replace, undef: :replace, replace: '?')
|
|
prev_was_text = true
|
|
end
|
|
end
|
|
end
|
|
bytes
|
|
end
|
|
|
|
# Prints already-formatted ESC/POS notation (see parse_escpos_notation) --
|
|
# for content prepared elsewhere or reprinted from the label repository,
|
|
# unlike print_receipt's title/body template.
|
|
def print_raw_escpos(content)
|
|
receipt = ReceiptBuilder.new
|
|
receipt.align(:left).raw(parse_escpos_notation(content))
|
|
receipt.feed(3).cut
|
|
receipt.print!(PRINTER_DEVICE_EPSON)
|
|
end
|
|
|
|
# Renders ZPL to a PNG via Labelary's public API for an inline preview.
|
|
# Fixed at the Datamax label size (4x6in @ 8dpmm ~= 203dpi) -- good enough
|
|
# for a thumbnail, not meant to be exact for other sizes.
|
|
def render_zpl_preview(zpl)
|
|
uri = URI('https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/')
|
|
res = Net::HTTP.post(uri, zpl, 'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8')
|
|
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
|
|
res.body
|
|
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 '/shipping/zpl' do
|
|
zpl = params[:zpl].to_s
|
|
name = params[:save_as].to_s
|
|
|
|
if zpl.strip.empty?
|
|
flash 'ZPL text is empty', 'warning'
|
|
redirect '/upload'
|
|
end
|
|
|
|
case params[:commit]
|
|
when 'save', 'save_and_print'
|
|
if name.empty?
|
|
flash 'Name is required to save', 'warning'
|
|
else
|
|
LABEL_REPO.save(name: name, type: 'zpl', content: zpl)
|
|
if params[:commit] == 'save_and_print'
|
|
begin
|
|
print_to_datamax(zpl)
|
|
flash "Saved '#{name}' and sent to printer", 'success'
|
|
rescue => e
|
|
flash "Saved '#{name}', but print failed (#{e.message})", 'danger'
|
|
end
|
|
else
|
|
flash "Saved '#{name}'", 'success'
|
|
end
|
|
end
|
|
else
|
|
# No explicit save/save_and_print action -- e.g. a plain API/CI call.
|
|
# labelprint stays a "dumb" print API here: just print, nothing saved.
|
|
begin
|
|
print_to_datamax(zpl)
|
|
flash 'ZPL sent to printer', 'success'
|
|
rescue => e
|
|
flash "ZPL print failed (#{e.message})", 'danger'
|
|
end
|
|
end
|
|
redirect '/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 '/epson' do
|
|
erb :epson
|
|
end
|
|
|
|
post '/epson/test-print' do
|
|
begin
|
|
print_epson_test_page
|
|
flash 'Test page sent to Epson printer', 'success'
|
|
rescue => e
|
|
flash "Epson test print failed (#{e.message})", 'danger'
|
|
end
|
|
redirect '/epson'
|
|
end
|
|
|
|
post '/epson/print' do
|
|
begin
|
|
print_receipt(title: params[:title].to_s, body: params[:body].to_s, qr: params[:qr].to_s)
|
|
flash 'Sent to Epson printer', 'success'
|
|
rescue => e
|
|
flash "Epson print failed (#{e.message})", 'danger'
|
|
end
|
|
redirect '/epson'
|
|
end
|
|
|
|
post '/epson/raw' do
|
|
content = params[:content].to_s
|
|
name = params[:save_as].to_s
|
|
|
|
if content.strip.empty?
|
|
flash 'Receipt text is empty', 'warning'
|
|
redirect '/epson'
|
|
end
|
|
|
|
case params[:commit]
|
|
when 'save', 'save_and_print'
|
|
if name.empty?
|
|
flash 'Name is required to save', 'warning'
|
|
else
|
|
LABEL_REPO.save(name: name, type: 'escpos', content: content)
|
|
if params[:commit] == 'save_and_print'
|
|
begin
|
|
print_raw_escpos(content)
|
|
flash "Saved '#{name}' and sent to printer", 'success'
|
|
rescue => e
|
|
flash "Saved '#{name}', but print failed (#{e.message})", 'danger'
|
|
end
|
|
else
|
|
flash "Saved '#{name}'", 'success'
|
|
end
|
|
end
|
|
else
|
|
# No explicit save/save_and_print action -- e.g. a plain API/CI call.
|
|
# labelprint stays a "dumb" print API here: just print, nothing saved.
|
|
begin
|
|
print_raw_escpos(content)
|
|
flash 'Sent to Epson printer', 'success'
|
|
rescue => e
|
|
flash "Epson print failed (#{e.message})", 'danger'
|
|
end
|
|
end
|
|
redirect '/epson'
|
|
end
|
|
|
|
get '/repository' do
|
|
@labels = LABEL_REPO.all
|
|
erb :repository
|
|
end
|
|
|
|
get '/repository/:id/edit' do
|
|
@label = LABEL_REPO.find(params[:id])
|
|
halt 404, 'unknown label' unless @label
|
|
|
|
erb :label_edit
|
|
end
|
|
|
|
post '/repository/:id' do
|
|
label = LABEL_REPO.update(params[:id], name: params[:name].to_s, content: params[:content].to_s)
|
|
halt 404, 'unknown label' unless label
|
|
|
|
flash "Saved '#{label['name']}'", 'success'
|
|
redirect '/repository'
|
|
end
|
|
|
|
get '/repository/:id/preview.png' do
|
|
label = LABEL_REPO.find(params[:id])
|
|
halt 404 unless label && label['type'] == 'zpl'
|
|
|
|
png = render_zpl_preview(label['content'])
|
|
halt 502, 'preview unavailable' unless png
|
|
|
|
content_type 'image/png'
|
|
png
|
|
end
|
|
|
|
post '/repository/:id/print' do
|
|
label = LABEL_REPO.find(params[:id])
|
|
halt 404, 'unknown label' unless label
|
|
|
|
begin
|
|
case label['type']
|
|
when 'zpl'
|
|
print_to_datamax(label['content'])
|
|
when 'escpos'
|
|
print_raw_escpos(label['content'])
|
|
end
|
|
flash "Printed '#{label['name']}'", 'success'
|
|
rescue => e
|
|
flash "Print failed (#{e.message})", 'danger'
|
|
end
|
|
redirect '/repository'
|
|
end
|
|
|
|
post '/repository/:id/delete' do
|
|
LABEL_REPO.delete(params[:id])
|
|
flash 'Label deleted', 'success'
|
|
redirect '/repository'
|
|
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
|