zpl_renderer 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/COMPATIBILITY.md +64 -0
- data/LICENSE.txt +24 -0
- data/README.md +147 -0
- data/exe/zpl-render +95 -0
- data/lib/zpl_renderer/binary.rb +63 -0
- data/lib/zpl_renderer/decoder.rb +42 -0
- data/lib/zpl_renderer/engine.rb +87 -0
- data/lib/zpl_renderer/errors.rb +14 -0
- data/lib/zpl_renderer/options.rb +139 -0
- data/lib/zpl_renderer/renderer.rb +23 -0
- data/lib/zpl_renderer/sanitizer.rb +156 -0
- data/lib/zpl_renderer/validation.rb +185 -0
- data/lib/zpl_renderer/version.rb +5 -0
- data/lib/zpl_renderer.rb +85 -0
- data/renderer/Cargo.lock +2473 -0
- data/renderer/Cargo.toml +25 -0
- data/renderer/src/main.rs +310 -0
- data/vendor/bin/SHA256SUMS +1 -0
- data/vendor/bin/x86_64-unknown-linux-gnu/zpl-renderer +0 -0
- metadata +113 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZplRenderer
|
|
4
|
+
# Normalized render options with validation for DPI, dimensions, and format.
|
|
5
|
+
class Options
|
|
6
|
+
SUPPORTED_DPI = {
|
|
7
|
+
203 => 8,
|
|
8
|
+
300 => 12,
|
|
9
|
+
600 => 24
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
SUPPORTED_DPMM = SUPPORTED_DPI.invert.freeze
|
|
13
|
+
SUPPORTED_FORMATS = %i[png pdf].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :format, :width_in, :height_in, :dpi, :dpmm, :index,
|
|
16
|
+
:timeout, :max_output_bytes, :strict, :unit, :clip_to_print_width
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
format: :png,
|
|
20
|
+
width: 4,
|
|
21
|
+
height: 6,
|
|
22
|
+
dpi: 203,
|
|
23
|
+
dpmm: nil,
|
|
24
|
+
index: 0,
|
|
25
|
+
timeout: 30,
|
|
26
|
+
max_output_bytes: 50 * 1024 * 1024,
|
|
27
|
+
strict: true,
|
|
28
|
+
unit: :inches,
|
|
29
|
+
clip_to_print_width: false
|
|
30
|
+
)
|
|
31
|
+
@format = format.to_sym
|
|
32
|
+
@unit = unit.to_sym
|
|
33
|
+
@index = Integer(index)
|
|
34
|
+
@timeout = Float(timeout)
|
|
35
|
+
@max_output_bytes = Integer(max_output_bytes)
|
|
36
|
+
@strict = !!strict
|
|
37
|
+
@clip_to_print_width = !!clip_to_print_width
|
|
38
|
+
|
|
39
|
+
validate_format!
|
|
40
|
+
validate_timeout!
|
|
41
|
+
validate_index!
|
|
42
|
+
|
|
43
|
+
if dpmm
|
|
44
|
+
@dpmm = Integer(dpmm)
|
|
45
|
+
raise ConfigurationError, "unsupported dpmm=#{@dpmm}; use 8, 12, or 24" unless SUPPORTED_DPMM.key?(@dpmm)
|
|
46
|
+
|
|
47
|
+
@dpi = SUPPORTED_DPMM[@dpmm]
|
|
48
|
+
else
|
|
49
|
+
@dpi = Integer(dpi)
|
|
50
|
+
raise ConfigurationError, "unsupported dpi=#{@dpi}; use 203, 300, or 600" unless SUPPORTED_DPI.key?(@dpi)
|
|
51
|
+
|
|
52
|
+
@dpmm = SUPPORTED_DPI[@dpi]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@width_in, @height_in = normalize_dimensions(width, height)
|
|
56
|
+
validate_dimensions!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def width_mm
|
|
60
|
+
width_in * 25.4
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def height_mm
|
|
64
|
+
height_in * 25.4
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Printer-dot canvas size used by the offline renderer (mm * dpmm, ceiled).
|
|
68
|
+
def pixel_width
|
|
69
|
+
(width_mm * dpmm).ceil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pixel_height
|
|
73
|
+
(height_mm * dpmm).ceil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Canvas width in dots used when widening narrow ^PW values, or nil when
|
|
77
|
+
# printer-style clipping is requested.
|
|
78
|
+
def sanitize_canvas_dots
|
|
79
|
+
clip_to_print_width ? nil : pixel_width
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def content_type
|
|
83
|
+
format == :pdf ? "application/pdf" : "image/png"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
{
|
|
88
|
+
format: format,
|
|
89
|
+
width_in: width_in,
|
|
90
|
+
height_in: height_in,
|
|
91
|
+
width_mm: width_mm,
|
|
92
|
+
height_mm: height_mm,
|
|
93
|
+
dpi: dpi,
|
|
94
|
+
dpmm: dpmm,
|
|
95
|
+
index: index,
|
|
96
|
+
timeout: timeout,
|
|
97
|
+
max_output_bytes: max_output_bytes,
|
|
98
|
+
strict: strict,
|
|
99
|
+
unit: unit,
|
|
100
|
+
clip_to_print_width: clip_to_print_width
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def normalize_dimensions(width, height)
|
|
107
|
+
w = Float(width)
|
|
108
|
+
h = Float(height)
|
|
109
|
+
|
|
110
|
+
case unit
|
|
111
|
+
when :inches, :in
|
|
112
|
+
[w, h]
|
|
113
|
+
when :millimeters, :mm
|
|
114
|
+
[w / 25.4, h / 25.4]
|
|
115
|
+
else
|
|
116
|
+
raise ConfigurationError, "unsupported unit=#{unit.inspect}; use :inches or :mm"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_format!
|
|
121
|
+
return if SUPPORTED_FORMATS.include?(format)
|
|
122
|
+
|
|
123
|
+
raise ConfigurationError, "unsupported format=#{format.inspect}; use :png or :pdf"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_timeout!
|
|
127
|
+
raise ConfigurationError, "timeout must be positive" unless timeout.positive?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_index!
|
|
131
|
+
raise ConfigurationError, "index must be >= 0" if index.negative?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_dimensions!
|
|
135
|
+
raise ConfigurationError, "width and height must be positive" if width_in <= 0 || height_in <= 0
|
|
136
|
+
raise ConfigurationError, "label dimensions exceed 24x24 inches" if width_in > 24 || height_in > 24
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZplRenderer
|
|
4
|
+
# High-level ZPL rendering entry point used by the public module API.
|
|
5
|
+
class Renderer
|
|
6
|
+
def initialize(engine: Engine.new)
|
|
7
|
+
@engine = engine
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render(zpl, **kwargs)
|
|
11
|
+
options = Options.new(**kwargs)
|
|
12
|
+
sanitized = Sanitizer.sanitize(zpl, canvas_dots: options.sanitize_canvas_dots)
|
|
13
|
+
Validation.validate!(sanitized.zpl, options)
|
|
14
|
+
@engine.render(sanitized.zpl, options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render_file(zpl, path, **kwargs)
|
|
18
|
+
bytes = render(zpl, **kwargs)
|
|
19
|
+
File.binwrite(path, bytes)
|
|
20
|
+
path
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZplRenderer
|
|
4
|
+
# Normalizes carrier ZPL quirks before validation/render.
|
|
5
|
+
#
|
|
6
|
+
# Two repairs are applied:
|
|
7
|
+
#
|
|
8
|
+
# 1. Invalid field origins such as +^FO464,--^GB2,126,2^FS+ from carrier ZPL.
|
|
9
|
+
# Invalid numeric params become 0 on printers; for a vertical rule we reuse
|
|
10
|
+
# the last horizontal section-divider Y so the divider lands in the middle
|
|
11
|
+
# band instead of y=0.
|
|
12
|
+
#
|
|
13
|
+
# 2. Print widths narrower than the requested canvas. The engine draws on a
|
|
14
|
+
# +^PW+-wide canvas and centers it, so fields near the right edge (some
|
|
15
|
+
# templates put rotated text at +^FO791+ under +^PW800+) are clipped. Widening +^PW+
|
|
16
|
+
# to the canvas and shifting +^LH+ by the same centering offset keeps every
|
|
17
|
+
# field in its original position while leaving nothing cut off.
|
|
18
|
+
class Sanitizer
|
|
19
|
+
Result = Struct.new(:zpl, :repairs, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
COMMAND = /\^(FO|FT)([^,\^]*),([^,\^]*)/i
|
|
22
|
+
PRINT_WIDTH = /\^PW(\d+)/i
|
|
23
|
+
LABEL_HOME = /\^LH(\d+),(\d+)/i
|
|
24
|
+
INVERTED_ORIENTATION = /\^POI/i
|
|
25
|
+
|
|
26
|
+
def initialize(zpl, canvas_dots: nil)
|
|
27
|
+
@zpl = zpl.to_s
|
|
28
|
+
@canvas_dots = canvas_dots
|
|
29
|
+
@repairs = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
zpl = repair_field_origins(@zpl)
|
|
34
|
+
zpl = expand_print_widths(zpl) if @canvas_dots
|
|
35
|
+
Result.new(zpl: zpl, repairs: @repairs)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.sanitize(zpl, canvas_dots: nil)
|
|
39
|
+
new(zpl, canvas_dots: canvas_dots).call
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def repair_field_origins(source)
|
|
45
|
+
last_horizontal_y = nil
|
|
46
|
+
output = +""
|
|
47
|
+
index = 0
|
|
48
|
+
|
|
49
|
+
source.scan(COMMAND) do
|
|
50
|
+
match = Regexp.last_match
|
|
51
|
+
output << source[index...match.begin(0)]
|
|
52
|
+
|
|
53
|
+
cmd = match[1].upcase
|
|
54
|
+
raw_x = match[2]
|
|
55
|
+
raw_y = match[3]
|
|
56
|
+
x = parse_coord(raw_x)
|
|
57
|
+
y = parse_coord(raw_y)
|
|
58
|
+
repaired = false
|
|
59
|
+
|
|
60
|
+
if x.nil?
|
|
61
|
+
x = 0
|
|
62
|
+
repaired = true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
trailing = source[match.end(0)..]
|
|
66
|
+
|
|
67
|
+
if y.nil?
|
|
68
|
+
y = vertical_rule?(trailing) && last_horizontal_y ? last_horizontal_y : 0
|
|
69
|
+
repaired = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if repaired
|
|
73
|
+
@repairs << {
|
|
74
|
+
command: "^#{cmd}",
|
|
75
|
+
from: "^#{cmd}#{raw_x},#{raw_y}",
|
|
76
|
+
to: "^#{cmd}#{x},#{y}",
|
|
77
|
+
reason: "non-numeric field origin coordinates"
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if (box = trailing.match(/\A\s*\^GB(\d+),(\d+),(\d+)/i))
|
|
82
|
+
last_horizontal_y = y if horizontal_rule?(box[1].to_i, box[2].to_i)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
output << "^#{cmd}#{x},#{y}"
|
|
86
|
+
index = match.end(0)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
output << source[index..]
|
|
90
|
+
output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def expand_print_widths(source)
|
|
94
|
+
source.split(/(?=\^XA)/i).map { |block| expand_block(block) }.join
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def expand_block(block)
|
|
98
|
+
return block if block.match?(INVERTED_ORIENTATION)
|
|
99
|
+
|
|
100
|
+
match = block.match(PRINT_WIDTH)
|
|
101
|
+
return block unless match
|
|
102
|
+
|
|
103
|
+
print_width = match[1].to_i
|
|
104
|
+
return block unless print_width.positive? && print_width < @canvas_dots
|
|
105
|
+
|
|
106
|
+
offset = (@canvas_dots - print_width) / 2
|
|
107
|
+
expanded = block.sub(PRINT_WIDTH, "^PW#{@canvas_dots}")
|
|
108
|
+
expanded = shift_label_home(expanded, offset, match)
|
|
109
|
+
|
|
110
|
+
@repairs << {
|
|
111
|
+
command: "^PW",
|
|
112
|
+
from: "^PW#{print_width}",
|
|
113
|
+
to: "^PW#{@canvas_dots}",
|
|
114
|
+
reason: "print width narrower than #{@canvas_dots}-dot canvas would clip right-edge fields " \
|
|
115
|
+
"(label home shifted by #{offset} dots to preserve placement)"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expanded
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def shift_label_home(block, offset, print_width_match)
|
|
122
|
+
return block if offset.zero?
|
|
123
|
+
|
|
124
|
+
if (home = block.match(LABEL_HOME))
|
|
125
|
+
block.sub(LABEL_HOME, "^LH#{home[1].to_i + offset},#{home[2].to_i}")
|
|
126
|
+
else
|
|
127
|
+
insert_at = print_width_match.begin(0) + "^PW#{@canvas_dots}".length
|
|
128
|
+
block.dup.insert(insert_at, "^LH#{offset},0")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_coord(raw)
|
|
133
|
+
token = raw.to_s.strip
|
|
134
|
+
return nil if token.empty?
|
|
135
|
+
return nil if token.match?(/\A-+\z/)
|
|
136
|
+
return nil unless token.match?(/\A-?\d+(\.\d+)?\z/)
|
|
137
|
+
|
|
138
|
+
token.to_i.abs
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def vertical_rule?(trailing)
|
|
142
|
+
box = trailing.match(/\A\s*\^GB(\d+),(\d+),(\d+)/i)
|
|
143
|
+
return false unless box
|
|
144
|
+
|
|
145
|
+
gw = box[1].to_i
|
|
146
|
+
gh = box[2].to_i
|
|
147
|
+
# Thin tall boxes are vertical dividers (e.g. ^GB2,126,2).
|
|
148
|
+
gh >= 20 && gh >= gw * 4
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def horizontal_rule?(width, height)
|
|
152
|
+
# Full-width section rules (e.g. ^GB755,2,2 / ^GB777,2,2), not H-box edges.
|
|
153
|
+
height <= 4 && width >= 200
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZplRenderer
|
|
4
|
+
# Static ZPL compatibility checks before rendering.
|
|
5
|
+
#
|
|
6
|
+
# Reports unsupported commands, missing label boundaries, and barcode
|
|
7
|
+
# scanability risks without mutating the source ZPL.
|
|
8
|
+
class Validation
|
|
9
|
+
Result = Struct.new(:ok?, :errors, :warnings, keyword_init: true) do
|
|
10
|
+
def raise_if_strict!(strict:)
|
|
11
|
+
return self if ok? || !strict
|
|
12
|
+
return self if errors.empty?
|
|
13
|
+
|
|
14
|
+
raise ValidationError, errors.join("; ")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
SUPPORTED_COMMANDS = %w[
|
|
19
|
+
XA XZ FO FT FD FS A CF FB FR FH FN FW FV
|
|
20
|
+
BC BE B2 B3 B7 BO BX BQ BD BY
|
|
21
|
+
GB GC GD GF GS IL XG
|
|
22
|
+
PW PO LH LR CI MU DF XF
|
|
23
|
+
PR PQ JM MD MN MT PM LL
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
UNSUPPORTED_COMMANDS = %w[
|
|
27
|
+
RF IS ID JB KD KL KM KP
|
|
28
|
+
WC WD WQ WR WS WT WV WW
|
|
29
|
+
SN SF
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
BARCODE_COMMANDS = %w[BC BE B2 B3 B7 BO BX BQ BD].freeze
|
|
33
|
+
|
|
34
|
+
KNOWN_COMMANDS = (
|
|
35
|
+
SUPPORTED_COMMANDS + UNSUPPORTED_COMMANDS +
|
|
36
|
+
%w[DG] # ~DG download graphic
|
|
37
|
+
).uniq.sort_by { |c| -c.length }.freeze
|
|
38
|
+
|
|
39
|
+
# Minimum module width in dots for reliable scanning at each DPI.
|
|
40
|
+
MIN_MODULE_WIDTH = {
|
|
41
|
+
203 => 2,
|
|
42
|
+
300 => 3,
|
|
43
|
+
600 => 4
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
def initialize(zpl, options)
|
|
47
|
+
@zpl = zpl.to_s
|
|
48
|
+
@options = options
|
|
49
|
+
@errors = []
|
|
50
|
+
@warnings = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call
|
|
54
|
+
check_not_empty!
|
|
55
|
+
check_label_bounds!
|
|
56
|
+
check_malformed_origins!
|
|
57
|
+
check_commands!
|
|
58
|
+
check_barcode_module_width!
|
|
59
|
+
check_qr_params!
|
|
60
|
+
check_code128_data!
|
|
61
|
+
|
|
62
|
+
Result.new(ok?: @errors.empty?, errors: @errors.dup, warnings: @warnings.dup)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.validate!(zpl, options)
|
|
66
|
+
new(zpl, options).call.raise_if_strict!(strict: options.strict)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def check_not_empty!
|
|
72
|
+
return unless @zpl.strip.empty?
|
|
73
|
+
|
|
74
|
+
@errors << "ZPL input is empty"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_label_bounds!
|
|
78
|
+
return if @zpl.strip.empty?
|
|
79
|
+
|
|
80
|
+
has_xa = @zpl.match?(/\^XA/i)
|
|
81
|
+
has_xz = @zpl.match?(/\^XZ/i)
|
|
82
|
+
|
|
83
|
+
@errors << "missing ^XA label start" unless has_xa
|
|
84
|
+
@errors << "missing ^XZ label end" unless has_xz
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_malformed_origins!
|
|
88
|
+
@zpl.scan(/\^(FO|FT)([^,\^]*),([^,\^]*)/i) do |cmd, x, y|
|
|
89
|
+
bad_x = !numeric_coord?(x)
|
|
90
|
+
bad_y = !numeric_coord?(y)
|
|
91
|
+
next unless bad_x || bad_y
|
|
92
|
+
|
|
93
|
+
@warnings << "malformed ^#{cmd.upcase} coordinates (#{x},#{y}); sanitizer should rewrite before render"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def numeric_coord?(raw)
|
|
98
|
+
token = raw.to_s.strip
|
|
99
|
+
return false if token.empty?
|
|
100
|
+
return false if token.match?(/\A-+\z/)
|
|
101
|
+
|
|
102
|
+
token.match?(/\A-?\d+(\.\d+)?\z/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_commands!
|
|
106
|
+
extract_commands.each do |cmd|
|
|
107
|
+
next if SUPPORTED_COMMANDS.include?(cmd)
|
|
108
|
+
next if cmd.match?(/\AA\z/)
|
|
109
|
+
|
|
110
|
+
if UNSUPPORTED_COMMANDS.include?(cmd)
|
|
111
|
+
message = "unsupported command ^#{cmd} (outside first-release compatibility matrix)"
|
|
112
|
+
if @options.strict
|
|
113
|
+
@errors << message
|
|
114
|
+
else
|
|
115
|
+
@warnings << message
|
|
116
|
+
end
|
|
117
|
+
else
|
|
118
|
+
@warnings << "command ^#{cmd} is not in the documented support matrix; rendering may be incomplete"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_commands
|
|
124
|
+
commands = []
|
|
125
|
+
@zpl.scan(/[\^~]([A-Za-z0-9]+)/) do |match|
|
|
126
|
+
token = match[0].upcase
|
|
127
|
+
known = KNOWN_COMMANDS.find { |cmd| token.start_with?(cmd) }
|
|
128
|
+
commands << (known || token[0, 3])
|
|
129
|
+
end
|
|
130
|
+
commands.uniq
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check_barcode_module_width!
|
|
134
|
+
return unless barcode_commands_present?
|
|
135
|
+
|
|
136
|
+
min = MIN_MODULE_WIDTH.fetch(@options.dpi, 2)
|
|
137
|
+
defaults = @zpl.scan(/\^BY(\d+)/i).flatten.map(&:to_i)
|
|
138
|
+
narrow = defaults.min
|
|
139
|
+
|
|
140
|
+
if narrow && narrow.positive? && narrow < min
|
|
141
|
+
@warnings << "barcode module width ^BY#{narrow} may be hard to scan at #{@options.dpi} DPI " \
|
|
142
|
+
"(recommended >= #{min} dots)"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return if defaults.any?
|
|
146
|
+
|
|
147
|
+
@warnings << "no ^BY module width set; default module width may be too narrow for scanners"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def check_qr_params!
|
|
151
|
+
@zpl.scan(/\^BQ[^\^]*/i).each do |match|
|
|
152
|
+
parts = match.sub(/\^BQ/i, "").split(",")
|
|
153
|
+
magnification = (parts[2] || "").to_s.strip
|
|
154
|
+
mag = magnification.empty? ? default_qr_magnification : magnification.to_i
|
|
155
|
+
|
|
156
|
+
if mag < 2
|
|
157
|
+
@warnings << "QR magnification #{mag} may produce unscannable modules at #{@options.dpi} DPI"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def check_code128_data!
|
|
163
|
+
fields = @zpl.split(/\^FS/i)
|
|
164
|
+
fields.each do |field|
|
|
165
|
+
next unless field.match?(/\^B(?:C|E|2|3|7|O|X|Q|D)/i)
|
|
166
|
+
next if field.match?(/\^FD.+/i)
|
|
167
|
+
|
|
168
|
+
@errors << "barcode field is missing ^FD data"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def barcode_commands_present?
|
|
173
|
+
BARCODE_COMMANDS.any? { |cmd| @zpl.match?(/\^#{cmd}/i) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def default_qr_magnification
|
|
177
|
+
case @options.dpi
|
|
178
|
+
when 203 then 2
|
|
179
|
+
when 300 then 3
|
|
180
|
+
when 600 then 6
|
|
181
|
+
else 2
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
data/lib/zpl_renderer.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "zpl_renderer/version"
|
|
4
|
+
require_relative "zpl_renderer/errors"
|
|
5
|
+
require_relative "zpl_renderer/options"
|
|
6
|
+
require_relative "zpl_renderer/binary"
|
|
7
|
+
require_relative "zpl_renderer/validation"
|
|
8
|
+
require_relative "zpl_renderer/sanitizer"
|
|
9
|
+
require_relative "zpl_renderer/engine"
|
|
10
|
+
require_relative "zpl_renderer/decoder"
|
|
11
|
+
require_relative "zpl_renderer/renderer"
|
|
12
|
+
|
|
13
|
+
# Offline ZPL to PNG/PDF converter for Ruby and Rails applications.
|
|
14
|
+
#
|
|
15
|
+
# png = ZplRenderer.render(zpl, format: :png, width: 4, height: 6, dpi: 203)
|
|
16
|
+
# ZplRenderer.render_file(zpl, "label.pdf", format: :pdf, dpi: 300)
|
|
17
|
+
#
|
|
18
|
+
module ZplRenderer
|
|
19
|
+
class << self
|
|
20
|
+
# Render ZPL to binary PNG or PDF bytes.
|
|
21
|
+
#
|
|
22
|
+
# @param zpl [String] raw ZPL label data
|
|
23
|
+
# @param format [Symbol] :png or :pdf
|
|
24
|
+
# @param width [Numeric] label width (inches by default)
|
|
25
|
+
# @param height [Numeric] label height (inches by default)
|
|
26
|
+
# @param dpi [Integer] 203, 300, or 600
|
|
27
|
+
# @param dpmm [Integer, nil] 8, 12, or 24 (overrides dpi when set)
|
|
28
|
+
# @param index [Integer] label index when multiple ^XA...^XZ blocks exist
|
|
29
|
+
# @param timeout [Numeric] render timeout in seconds
|
|
30
|
+
# @param max_output_bytes [Integer] hard cap on output size
|
|
31
|
+
# @param strict [Boolean] raise on unsupported commands
|
|
32
|
+
# @param unit [Symbol] :inches or :mm
|
|
33
|
+
# @param clip_to_print_width [Boolean] retain clipping from a narrow ^PW
|
|
34
|
+
# @return [String] binary PNG or PDF data
|
|
35
|
+
def render(zpl, **kwargs)
|
|
36
|
+
default_renderer.render(zpl, **kwargs)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Render ZPL and write the result to +path+.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] the output path
|
|
42
|
+
def render_file(zpl, path, **kwargs)
|
|
43
|
+
default_renderer.render_file(zpl, path, **kwargs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Validate ZPL without rendering.
|
|
47
|
+
#
|
|
48
|
+
# @return [ZplRenderer::Validation::Result]
|
|
49
|
+
def validate(zpl, **kwargs)
|
|
50
|
+
options = Options.new(**kwargs)
|
|
51
|
+
sanitized = Sanitizer.sanitize(zpl, canvas_dots: options.sanitize_canvas_dots)
|
|
52
|
+
result = Validation.new(sanitized.zpl, options).call
|
|
53
|
+
sanitized.repairs.each do |repair|
|
|
54
|
+
result.warnings << "sanitized #{repair[:from]} -> #{repair[:to]} (#{repair[:reason]})"
|
|
55
|
+
end
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Sanitize carrier ZPL quirks (e.g. ^FO464,-- and narrow ^PW).
|
|
60
|
+
#
|
|
61
|
+
# @param canvas_dots [Integer, nil] widen ^PW up to this canvas width
|
|
62
|
+
# @return [ZplRenderer::Sanitizer::Result]
|
|
63
|
+
def sanitize(zpl, canvas_dots: nil)
|
|
64
|
+
Sanitizer.sanitize(zpl, canvas_dots: canvas_dots)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Content-Type helper for Rails send_data responses.
|
|
68
|
+
def content_type(format)
|
|
69
|
+
Options.new(format: format).content_type
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Decode barcodes/QR codes from a rendered PNG using an independent ZXing engine.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<ZplRenderer::Decoder::Hit>]
|
|
75
|
+
def decode_png(png_bytes)
|
|
76
|
+
Decoder.new.decode_png(png_bytes)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def default_renderer
|
|
82
|
+
@default_renderer ||= Renderer.new
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|