diff --git a/app.rb b/app.rb index 52ad58b..42aa4e7 100644 --- a/app.rb +++ b/app.rb @@ -107,16 +107,64 @@ helpers do receipt.print!(PRINTER_DEVICE_EPSON) end - # Prints already-formatted ESC/POS text as-is (just the reset + code page - # prefix and a trailing cut) -- for content prepared elsewhere or reprinted - # from the label repository, unlike print_receipt's title/body template. + 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 + # Each `#...` trailing comment is stripped, then each line's + # whitespace-separated tokens become either 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 + line.sub(/#.*/, '').split.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).text(content) + 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 @@ -166,7 +214,6 @@ get '/' do end get '/upload' do - @labels = LABEL_REPO.all(type: 'zpl') erb :upload end @@ -234,7 +281,6 @@ post '/test-print' do end get '/epson' do - @labels = LABEL_REPO.all(type: 'escpos') erb :epson end @@ -264,6 +310,37 @@ post '/epson/raw' do redirect '/epson' end +get '/labels' do + @labels = LABEL_REPO.all + erb :labels +end + +get '/labels/:id/edit' do + @label = LABEL_REPO.find(params[:id]) + halt 404, 'unknown label' unless @label + + erb :label_edit +end + +post '/labels/: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 '/labels' +end + +get '/labels/: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 '/labels/:id/print' do label = LABEL_REPO.find(params[:id]) halt 404, 'unknown label' unless label @@ -279,16 +356,13 @@ post '/labels/:id/print' do rescue => e flash "Print failed (#{e.message})", 'danger' end - redirect_target = label['type'] == 'zpl' ? '/upload' : '/epson' - redirect redirect_target + redirect '/labels' end post '/labels/:id/delete' do - label = LABEL_REPO.find(params[:id]) - redirect_target = label && label['type'] == 'zpl' ? '/upload' : '/epson' LABEL_REPO.delete(params[:id]) flash 'Label deleted', 'success' - redirect redirect_target + redirect '/labels' end get '/brother' do diff --git a/lib/label_repository.rb b/lib/label_repository.rb index 6ca6875..74cc88e 100644 --- a/lib/label_repository.rb +++ b/lib/label_repository.rb @@ -34,6 +34,16 @@ class LabelRepository label end + def update(id, name:, content:) + label = find(id) + return nil unless label + + label['name'] = name + label['content'] = content + File.write(path_for(id), JSON.generate(label)) + label + end + def delete(id) File.delete(path_for(id)) if File.exist?(path_for(id)) end diff --git a/lib/receipt_builder.rb b/lib/receipt_builder.rb index 731695c..a7ae044 100644 --- a/lib/receipt_builder.rb +++ b/lib/receipt_builder.rb @@ -39,6 +39,13 @@ class ReceiptBuilder self end + # Appends already-encoded/raw bytes as-is (e.g. pre-parsed ESC/POS command + # sequences), bypassing the CP852 text encoding `text` applies. + def raw(bytes) + @buffer << bytes + self + end + def align(mode) @buffer << case mode when :center then ALIGN_CENT diff --git a/views/_navbar.erb b/views/_navbar.erb index d816717..fcd90e7 100644 --- a/views/_navbar.erb +++ b/views/_navbar.erb @@ -18,6 +18,9 @@ + <% if request.path.start_with?('/brother') %> Printer: diff --git a/views/epson.erb b/views/epson.erb index 655bc47..7db4cd0 100644 --- a/views/epson.erb +++ b/views/epson.erb @@ -17,8 +17,20 @@
-
Free text (already-formatted ESC/POS)
-

Sent as-is (just reset + code page prefix and a trailing cut), no title/body template applied.

+
Free text (ESC/POS mnemonic notation)
+

+ One instruction per line: ESC/GS/FS/LF/CR/HT/NUL as + command names, 0xNN as a raw byte, anything else as literal text. Trailing # comment is ignored. Example: +

+
ESC a 0x01   # Align: Center
+Hello        # literal text
+LF
+ESC E 0x01   # Bold on
+World
+LF
+ESC E 0x00   # Bold off
+GS k 0x03 12345678 0x00   # EAN8 barcode
+GS V 0x00    # Full cut
@@ -34,22 +46,4 @@
- <% if @labels && !@labels.empty? %> -
-
Saved receipts
-
- <% @labels.each do |label| %> -
- <%= label['name'] %> -
-
- -
-
- -
-
-
- <% end %> -
- <% end %> +

See saved receipts →

diff --git a/views/label_edit.erb b/views/label_edit.erb new file mode 100644 index 0000000..cd46496 --- /dev/null +++ b/views/label_edit.erb @@ -0,0 +1,20 @@ +

Edit label

+ +<% if @label['type'] == 'zpl' %> + ZPL · Datamax +<% else %> + ESC/POS · Epson +<% end %> + +
+
+ + +
+
+ + +
+ + Cancel +
diff --git a/views/labels.erb b/views/labels.erb new file mode 100644 index 0000000..426dbd4 --- /dev/null +++ b/views/labels.erb @@ -0,0 +1,39 @@ +

Saved labels & receipts

+ +<% if @labels.empty? %> +

Nothing saved yet -- use "Save as" on Shipping labels or Receipts.

+<% else %> +
+ <% @labels.each do |label| %> +
+
+
+ <% if label['type'] == 'zpl' %> + ZPL · Datamax + <% else %> + ESC/POS · Epson + <% end %> + <%= label['name'] %> +
+
+
+ +
+ Edit +
+ +
+
+
+ +
+ <% if label['type'] == 'zpl' %> + preview + <% else %> +
<%= label['content'] %>
+ <% end %> +
+
+ <% end %> +
+<% end %> diff --git a/views/upload.erb b/views/upload.erb index c9071e5..2d0769e 100644 --- a/views/upload.erb +++ b/views/upload.erb @@ -32,22 +32,4 @@ - <% if @labels && !@labels.empty? %> -
-
Saved labels
-
- <% @labels.each do |label| %> -
- <%= label['name'] %> -
-
- -
-
- -
-
-
- <% end %> -
- <% end %> +

See saved labels →