Unified /labels page (badges, edit, preview), mnemonic ESC/POS parser for the raw receipt field
deploy / deploy (push) Successful in 10s
deploy / deploy (push) Successful in 10s
- Saved labels/receipts now show on one page instead of split across Shipping labels and Receipts, each with a badge for its type/printer, Edit (name+content, in place), Print, and Delete. - ZPL labels get a real visual preview via Labelary's public render API; ESC/POS ones show their raw content (no good general preview for that). - The Receipts free-text field only ever encoded whatever was typed as literal printable characters -- pasting mnemonic ESC/POS notation (ESC/GS/LF as command names, 0xNN as raw bytes) just printed as text instead of being interpreted. Added a small line-based parser for that notation (ReceiptBuilder#raw appends already-resolved bytes verbatim), verified byte-for-byte against an example with align/bold/barcode/cut.
This commit is contained in:
1 parent
66e2e7a5c0
commit
148f7dc248
8 files changed
+180
-51
No files matched your search
@@ -107,16 +107,64 @@ helpers do
|
|||||||
receipt.print!(PRINTER_DEVICE_EPSON)
|
receipt.print!(PRINTER_DEVICE_EPSON)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prints already-formatted ESC/POS text as-is (just the reset + code page
|
ESCPOS_MNEMONICS = {
|
||||||
# prefix and a trailing cut) -- for content prepared elsewhere or reprinted
|
'ESC' => "\x1B", 'GS' => "\x1D", 'FS' => "\x1C", 'DLE' => "\x10",
|
||||||
# from the label repository, unlike print_receipt's title/body template.
|
'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)
|
def print_raw_escpos(content)
|
||||||
receipt = ReceiptBuilder.new
|
receipt = ReceiptBuilder.new
|
||||||
receipt.align(:left).text(content)
|
receipt.align(:left).raw(parse_escpos_notation(content))
|
||||||
receipt.feed(3).cut
|
receipt.feed(3).cut
|
||||||
receipt.print!(PRINTER_DEVICE_EPSON)
|
receipt.print!(PRINTER_DEVICE_EPSON)
|
||||||
end
|
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
|
# Transparently reverse-proxies the current request to one of the
|
||||||
# brother_ql_web instances, so all three printers (web UI and API alike)
|
# brother_ql_web instances, so all three printers (web UI and API alike)
|
||||||
# are reachable through this single sinatra port instead of their own
|
# are reachable through this single sinatra port instead of their own
|
||||||
@@ -166,7 +214,6 @@ get '/' do
|
|||||||
end
|
end
|
||||||
|
|
||||||
get '/upload' do
|
get '/upload' do
|
||||||
@labels = LABEL_REPO.all(type: 'zpl')
|
|
||||||
erb :upload
|
erb :upload
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -234,7 +281,6 @@ post '/test-print' do
|
|||||||
end
|
end
|
||||||
|
|
||||||
get '/epson' do
|
get '/epson' do
|
||||||
@labels = LABEL_REPO.all(type: 'escpos')
|
|
||||||
erb :epson
|
erb :epson
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -264,6 +310,37 @@ post '/epson/raw' do
|
|||||||
redirect '/epson'
|
redirect '/epson'
|
||||||
end
|
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
|
post '/labels/:id/print' do
|
||||||
label = LABEL_REPO.find(params[:id])
|
label = LABEL_REPO.find(params[:id])
|
||||||
halt 404, 'unknown label' unless label
|
halt 404, 'unknown label' unless label
|
||||||
@@ -279,16 +356,13 @@ post '/labels/:id/print' do
|
|||||||
rescue => e
|
rescue => e
|
||||||
flash "Print failed (#{e.message})", 'danger'
|
flash "Print failed (#{e.message})", 'danger'
|
||||||
end
|
end
|
||||||
redirect_target = label['type'] == 'zpl' ? '/upload' : '/epson'
|
redirect '/labels'
|
||||||
redirect redirect_target
|
|
||||||
end
|
end
|
||||||
|
|
||||||
post '/labels/:id/delete' do
|
post '/labels/:id/delete' do
|
||||||
label = LABEL_REPO.find(params[:id])
|
|
||||||
redirect_target = label && label['type'] == 'zpl' ? '/upload' : '/epson'
|
|
||||||
LABEL_REPO.delete(params[:id])
|
LABEL_REPO.delete(params[:id])
|
||||||
flash 'Label deleted', 'success'
|
flash 'Label deleted', 'success'
|
||||||
redirect redirect_target
|
redirect '/labels'
|
||||||
end
|
end
|
||||||
|
|
||||||
get '/brother' do
|
get '/brother' do
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ class LabelRepository
|
|||||||
label
|
label
|
||||||
end
|
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)
|
def delete(id)
|
||||||
File.delete(path_for(id)) if File.exist?(path_for(id))
|
File.delete(path_for(id)) if File.exist?(path_for(id))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class ReceiptBuilder
|
|||||||
self
|
self
|
||||||
end
|
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)
|
def align(mode)
|
||||||
@buffer << case mode
|
@buffer << case mode
|
||||||
when :center then ALIGN_CENT
|
when :center then ALIGN_CENT
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <%= 'active' if request.path == '/epson' %>" href="/epson">Receipts</a>
|
<a class="nav-link <%= 'active' if request.path == '/epson' %>" href="/epson">Receipts</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <%= 'active' if request.path.start_with?('/labels') %>" href="/labels">Labels</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<% if request.path.start_with?('/brother') %>
|
<% if request.path.start_with?('/brother') %>
|
||||||
<span class="navbar-text me-2">Printer:</span>
|
<span class="navbar-text me-2">Printer:</span>
|
||||||
|
|||||||
+15
-21
@@ -17,8 +17,20 @@
|
|||||||
|
|
||||||
<hr class="mt-4">
|
<hr class="mt-4">
|
||||||
|
|
||||||
<h5>Free text (already-formatted ESC/POS)</h5>
|
<h5>Free text (ESC/POS mnemonic notation)</h5>
|
||||||
<p>Sent as-is (just reset + code page prefix and a trailing cut), no title/body template applied.</p>
|
<p>
|
||||||
|
One instruction per line: <code>ESC</code>/<code>GS</code>/<code>FS</code>/<code>LF</code>/<code>CR</code>/<code>HT</code>/<code>NUL</code> as
|
||||||
|
command names, <code>0xNN</code> as a raw byte, anything else as literal text. Trailing <code># comment</code> is ignored. Example:
|
||||||
|
</p>
|
||||||
|
<pre class="bg-light p-2"><code>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</code></pre>
|
||||||
<form action="/epson/raw" method="POST">
|
<form action="/epson/raw" method="POST">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<textarea name="content" class="form-control" rows="8" style="font-family: monospace;"></textarea>
|
<textarea name="content" class="form-control" rows="8" style="font-family: monospace;"></textarea>
|
||||||
@@ -34,22 +46,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<% if @labels && !@labels.empty? %>
|
<p class="mt-4"><a href="/labels">See saved receipts →</a></p>
|
||||||
<hr class="mt-4">
|
|
||||||
<h5>Saved receipts</h5>
|
|
||||||
<div class="list-group">
|
|
||||||
<% @labels.each do |label| %>
|
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<%= label['name'] %>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<form action="/labels/<%= label['id'] %>/print" method="POST">
|
|
||||||
<button class="btn btn-sm btn-secondary" type="submit">Print</button>
|
|
||||||
</form>
|
|
||||||
<form action="/labels/<%= label['id'] %>/delete" method="POST">
|
|
||||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<h4>Edit label</h4>
|
||||||
|
|
||||||
|
<% if @label['type'] == 'zpl' %>
|
||||||
|
<span class="badge bg-primary">ZPL · Datamax</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="badge bg-info text-dark">ESC/POS · Epson</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<form action="/labels/<%= @label['id'] %>" method="POST" class="mt-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Name</label>
|
||||||
|
<input name="name" class="form-control" type="text" id="name" value="<%= @label['name'] %>" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="content" class="form-label">Content</label>
|
||||||
|
<textarea name="content" class="form-control" id="content" rows="12" style="font-family: monospace;"><%= @label['content'] %></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" type="submit">Save</button>
|
||||||
|
<a href="/labels" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<h4>Saved labels & receipts</h4>
|
||||||
|
|
||||||
|
<% if @labels.empty? %>
|
||||||
|
<p>Nothing saved yet -- use "Save as" on <a href="/upload">Shipping labels</a> or <a href="/epson">Receipts</a>.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="list-group">
|
||||||
|
<% @labels.each do |label| %>
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<% if label['type'] == 'zpl' %>
|
||||||
|
<span class="badge bg-primary">ZPL · Datamax</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="badge bg-info text-dark">ESC/POS · Epson</span>
|
||||||
|
<% end %>
|
||||||
|
<strong class="ms-2"><%= label['name'] %></strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<form action="/labels/<%= label['id'] %>/print" method="POST">
|
||||||
|
<button class="btn btn-sm btn-secondary" type="submit">Print</button>
|
||||||
|
</form>
|
||||||
|
<a href="/labels/<%= label['id'] %>/edit" class="btn btn-sm btn-outline-secondary">Edit</a>
|
||||||
|
<form action="/labels/<%= label['id'] %>/delete" method="POST">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<% if label['type'] == 'zpl' %>
|
||||||
|
<img src="/labels/<%= label['id'] %>/preview.png" alt="preview" style="max-height: 150px;" onerror="this.remove()" />
|
||||||
|
<% else %>
|
||||||
|
<pre class="bg-light p-2 mb-0" style="max-height: 150px; overflow: auto;"><%= label['content'] %></pre>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
+1
-19
@@ -32,22 +32,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<% if @labels && !@labels.empty? %>
|
<p class="mt-4"><a href="/labels">See saved labels →</a></p>
|
||||||
<hr class="mt-4">
|
|
||||||
<h5>Saved labels</h5>
|
|
||||||
<div class="list-group">
|
|
||||||
<% @labels.each do |label| %>
|
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<%= label['name'] %>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<form action="/labels/<%= label['id'] %>/print" method="POST">
|
|
||||||
<button class="btn btn-sm btn-secondary" type="submit">Print</button>
|
|
||||||
</form>
|
|
||||||
<form action="/labels/<%= label['id'] %>/delete" method="POST">
|
|
||||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
Reference in new issue
Block a user