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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZplRenderer
4
+ VERSION = "0.1.2"
5
+ end
@@ -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