commit 2ea45b5f21d5158df6ee397eb72dfa4d73eeb609 Author: Roman Priesol Date: Sat Sep 12 00:22:35 2026 +0200 Initial commit: ZPL-native PDF printing, drop CUPS dependency diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b573bec --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.session_secret +public/files/* +!public/files/.gitkeep diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..1108559 --- /dev/null +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6701673 --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..685db40 --- /dev/null +++ b/app.rb @@ -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 diff --git a/config.json b/config.json new file mode 100644 index 0000000..2884ebe --- /dev/null +++ b/config.json @@ -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" + } + ] +} diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..8fd417a --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +require 'bundler' + +Bundler.require + +require './app' +run Sinatra::Application diff --git a/gemset.gems b/gemset.gems new file mode 100644 index 0000000..61bdffb --- /dev/null +++ b/gemset.gems @@ -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 diff --git a/manifest.yml b/manifest.yml new file mode 100644 index 0000000..56b6276 --- /dev/null +++ b/manifest.yml @@ -0,0 +1,7 @@ +--- +applications: +- name: bootstrap + memory: 128M + instances: 1 + url: bootstrap.${target-base} + path: . diff --git a/public/files/.gitkeep b/public/files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test-label.pdf b/test-label.pdf new file mode 100644 index 0000000..c173738 Binary files /dev/null and b/test-label.pdf differ diff --git a/tools/build_test_label.rb b/tools/build_test_label.rb new file mode 100644 index 0000000..216a700 --- /dev/null +++ b/tools/build_test_label.rb @@ -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 diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 0000000..2207b5f --- /dev/null +++ b/views/index.erb @@ -0,0 +1,61 @@ +
+
+
Brother Label Designer
+

A web service to print labels on Brother QL label printers.

+ QL-560 + QL-700 + QL-1100 +
+
+
+
+
Sticker Factory
+

Print images, text and cats on a Brother QL label printer

+ Go +
+
+
+
+
Cups
+

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.

+ Go +
+
+
+
+
Cockpit
+

Cockpit is a web-based graphical interface for servers, intended for everyone

+ Go +
+
+
+
+
ZPL Viewer
+

An useful tool for labels in ZPL format.

+ Go +
+
+
+
+
Creality Sonic Pad
+

3D Printer

+ Fluidd + Mainsail + Moonraker +
+
+
+
+
Bigtreetech Pad 7 CNC
+

CNC

+ Mainsail + Moonraker +
+
+
+
+
Bigtreetech Pad 7 CAN
+

CAN

+ Mainsail +
+
diff --git a/views/layout.erb b/views/layout.erb new file mode 100644 index 0000000..55d886e --- /dev/null +++ b/views/layout.erb @@ -0,0 +1,56 @@ + + + + Print server + + + + + + + +
+ +
+ <% if @alert_success then %> +
<%=@alert_success%>
+ <% end %> + <% if @alert_danger then %> +
<%=@alert_danger%>
+ <% end %> + <% if @alert_warning then %> +
<%=@alert_warning%>
+ <% end %> +<%= yield %> +
+
+ + + + + + diff --git a/views/login_form.erb b/views/login_form.erb new file mode 100644 index 0000000..a765bd0 --- /dev/null +++ b/views/login_form.erb @@ -0,0 +1,4 @@ +
+ + +
diff --git a/views/upload.erb b/views/upload.erb new file mode 100644 index 0000000..64baf5c --- /dev/null +++ b/views/upload.erb @@ -0,0 +1,11 @@ +
+
+ + +
+ +
+ +
+ +