zpl_render 0.1.0

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,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZplRender
4
+ # Executes a parsed ZPL command stream and paints labels onto Canvases.
5
+ # One Canvas is produced per ^XA...^XZ format.
6
+ class Renderer
7
+ DEFAULT_MODULE_WIDTH = 2
8
+ DEFAULT_RATIO = 3.0
9
+ DEFAULT_BARCODE_HEIGHT = 10
10
+
11
+ attr_reader :warnings
12
+
13
+ # strict: false (default) records recoverable problems in #warnings and
14
+ # keeps rendering; true raises ZplRender::Error on the first problem
15
+ # (bad field data, unsupported command).
16
+ def initialize(dpmm: 8, width_in: 4.0, height_in: 6.0, strict: false)
17
+ raise Error, "dpmm must be a positive number (got #{dpmm.inspect})" unless dpmm.is_a?(Numeric) && dpmm.positive?
18
+ unless width_in.is_a?(Numeric) && width_in.positive? && height_in.is_a?(Numeric) && height_in.positive?
19
+ raise Error, "label dimensions must be positive numbers (got #{width_in.inspect} x #{height_in.inspect})"
20
+ end
21
+
22
+ @dpmm = dpmm
23
+ @strict = strict
24
+ dpi = (25.4 * dpmm).round # printers advertise 203/300/600 dpi nominal
25
+ @default_width = (width_in * dpi).floor
26
+ @default_height = (height_in * dpi).floor
27
+ @warnings = []
28
+ @images = {} # ~DG store, keyed by "R:NAME.GRF"
29
+ end
30
+
31
+ def render(zpl)
32
+ commands = Parser.parse(zpl)
33
+ labels = []
34
+ reset_label_state
35
+
36
+ commands.each do |cmd|
37
+ case cmd.name
38
+ when "XA"
39
+ reset_label_state
40
+ when "XZ"
41
+ flush_field
42
+ labels << @canvas if @canvas
43
+ @canvas = nil
44
+ else
45
+ execute(cmd)
46
+ end
47
+ end
48
+ labels << @canvas if @canvas && dirty? # tolerate missing ^XZ
49
+ labels
50
+ end
51
+
52
+ private
53
+
54
+ def reset_label_state
55
+ @canvas = Canvas.new(@default_width, @default_height)
56
+ @lh = [0, 0]
57
+ @fw = :n
58
+ @default_font = { name: "0", h: nil, w: nil }
59
+ @by = { module: DEFAULT_MODULE_WIDTH, ratio: DEFAULT_RATIO, height: DEFAULT_BARCODE_HEIGHT }
60
+ @encoding = 0
61
+ @pw = nil
62
+ @dirty = false
63
+ reset_field
64
+ end
65
+
66
+ def print_width_offset
67
+ return 0 unless @pw && @pw < @canvas.width
68
+
69
+ (@canvas.width - @pw) / 2
70
+ end
71
+
72
+ def reset_field
73
+ @field = {
74
+ x: 0, y: 0, typeset: false, origin_set: false,
75
+ font: nil, barcode: nil, graphic: nil,
76
+ reverse: false, block: nil, hex: false, hex_char: "_",
77
+ data: nil, orientation: nil
78
+ }
79
+ end
80
+
81
+ def dirty?
82
+ @dirty
83
+ end
84
+
85
+ def execute(cmd)
86
+ p = cmd.args
87
+ case cmd.name
88
+ when "FO", "FT"
89
+ flush_field
90
+ @field[:x] = int(p[0], 0) + @lh[0] + print_width_offset
91
+ @field[:y] = int(p[1], 0) + @lh[1]
92
+ @field[:typeset] = cmd.name == "FT"
93
+ @field[:origin_set] = true
94
+ when "FS"
95
+ flush_field
96
+ when "FD", "FV"
97
+ @field[:data] = cmd.params
98
+ when "SN"
99
+ @field[:data] = cmd.args[0].to_s
100
+ when "FH"
101
+ @field[:hex] = true
102
+ @field[:hex_char] = p[0].to_s.empty? ? "_" : p[0][0]
103
+ when "FR"
104
+ @field[:reverse] = true
105
+ when "FB"
106
+ @field[:block] = {
107
+ width: int(p[0], 0), lines: int(p[1], 1),
108
+ line_gap: int(p[2], 0), just: (p[3] || "L").upcase
109
+ }
110
+ when "A"
111
+ @field[:font] = font_from_params(p)
112
+ @field[:orientation] = @field[:font][:orientation]
113
+ when "A@"
114
+ # downloaded scalable font: fall back to default font metrics
115
+ @field[:font] = { name: "0", h: int(p[1], nil), w: int(p[2], nil) }
116
+ @field[:orientation] = orient(p[0])
117
+ when "CF"
118
+ @default_font = { name: (p[0].to_s.empty? ? @default_font[:name] : p[0]),
119
+ h: int(p[1], @default_font[:h]), w: int(p[2], @default_font[:w]) }
120
+ when "CI"
121
+ @encoding = int(p[0], 0)
122
+ when "FW"
123
+ @fw = orient(p[0])
124
+ # ^FW between ^A and ^FD overrides the field's orientation,
125
+ # matching printer behavior (last orientation wins).
126
+ @field[:orientation] = @fw if @field[:origin_set]
127
+ when "BY"
128
+ @by[:module] = int(p[0], @by[:module]).clamp(1, 10)
129
+ @by[:ratio] = (p[1].to_s.empty? ? @by[:ratio] : p[1].to_f).clamp(2.0, 3.0)
130
+ @by[:height] = int(p[2], @by[:height])
131
+ when "BC"
132
+ @field[:barcode] = {
133
+ type: :code128, orientation: orient(p[0]),
134
+ height: int(p[1], nil), interp: yn(p[2], true), above: yn(p[3], false),
135
+ check: yn(p[4], false), mode: (p[5].to_s.empty? ? "N" : p[5].upcase)
136
+ }
137
+ @field[:orientation] = @field[:barcode][:orientation]
138
+ when "B3"
139
+ @field[:barcode] = {
140
+ type: :code39, orientation: orient(p[0]), check: yn(p[1], false),
141
+ height: int(p[2], nil), interp: yn(p[3], true), above: yn(p[4], false)
142
+ }
143
+ @field[:orientation] = @field[:barcode][:orientation]
144
+ when "B2"
145
+ @field[:barcode] = {
146
+ type: :i2of5, orientation: orient(p[0]), height: int(p[1], nil),
147
+ interp: yn(p[2], true), above: yn(p[3], false), check: yn(p[4], false)
148
+ }
149
+ @field[:orientation] = @field[:barcode][:orientation]
150
+ when "B7"
151
+ @field[:barcode] = {
152
+ type: :pdf417, orientation: orient(p[0]), row_height: int(p[1], nil),
153
+ security: int(p[2], nil), columns: int(p[3], 0), rows: int(p[4], 0),
154
+ truncate: yn(p[5], false)
155
+ }
156
+ @field[:orientation] = @field[:barcode][:orientation]
157
+ when "BQ"
158
+ @field[:barcode] = {
159
+ type: :qr, orientation: orient(p[0]), model: int(p[1], 2),
160
+ magnification: int(p[2], default_qr_magnification),
161
+ ecc: (p[3].to_s.empty? ? "Q" : p[3].upcase), mask: int(p[4], 7)
162
+ }
163
+ @field[:orientation] = @field[:barcode][:orientation]
164
+ when "GB"
165
+ @field[:graphic] = { type: :box, w: int(p[0], 1), h: int(p[1], 1),
166
+ t: int(p[2], 1), color: color(p[3]), round: int(p[4], 0) }
167
+ when "GC"
168
+ @field[:graphic] = { type: :circle, d: int(p[0], 3), t: int(p[1], 1), color: color(p[2]) }
169
+ when "GD"
170
+ @field[:graphic] = { type: :diagonal, w: int(p[0], 3), h: int(p[1], 3),
171
+ t: int(p[2], 1), color: color(p[3]), dir: (p[4] || "R").upcase }
172
+ when "GF"
173
+ @field[:graphic] = parse_gf(cmd.params)
174
+ when "XG"
175
+ name = normalize_image_name(p[0])
176
+ mx = int(p[1], 1).clamp(1, 10)
177
+ my = int(p[2], 1).clamp(1, 10)
178
+ @field[:graphic] = { type: :image, name: name, mx: mx, my: my }
179
+ when "DG"
180
+ store_dg(cmd.params)
181
+ when "PW"
182
+ # ^PW sets the printable width; the label canvas never shrinks and
183
+ # a narrower print area is centered on the label, like the printer.
184
+ @pw = int(p[0], nil)
185
+ resize_canvas(width: [@pw || 0, @canvas.width].max)
186
+ when "LL"
187
+ resize_canvas(height: [int(p[0], @canvas.height), @canvas.height].max)
188
+ when "LH"
189
+ @lh = [int(p[0], 0), int(p[1], 0)]
190
+ when "FX", "PQ", "MC", "MD", "MM", "MN", "MT", "PR", "PO", "LR", "LS",
191
+ "CW", "JM", "PM", "SD", "SZ", "XB", "DF", "EG", "ID", "TA", "JU",
192
+ "PF", "PH", "SL", "SO", "ST", "SX", "SQ", "WD", "HH", "HS", "JS"
193
+ # no visual effect at render time; ignored
194
+ else
195
+ problem("unsupported command: #{cmd.prefix == :tilde ? '~' : '^'}#{cmd.name}")
196
+ end
197
+ end
198
+
199
+ # ---- field flushing -----------------------------------------------------
200
+
201
+ def flush_field
202
+ f = @field
203
+ begin
204
+ if f[:graphic] && f[:origin_set]
205
+ draw_graphic(f)
206
+ elsif f[:data]
207
+ if f[:barcode]
208
+ draw_barcode(f)
209
+ else
210
+ draw_text(f)
211
+ end
212
+ end
213
+ rescue StandardError => e
214
+ problem("#{field_label(f)} at (#{f[:x]},#{f[:y]}) failed: #{e.message}")
215
+ end
216
+ reset_field
217
+ end
218
+
219
+ def field_label(f)
220
+ if f[:barcode]
221
+ "#{f[:barcode][:type]} barcode field"
222
+ elsif f[:graphic]
223
+ "#{f[:graphic][:type]} graphic field"
224
+ else
225
+ "text field"
226
+ end
227
+ end
228
+
229
+ # Record a recoverable problem, or raise it in strict mode. When called
230
+ # from a rescue block, Ruby chains the original exception as #cause.
231
+ def problem(message)
232
+ raise Error, message if @strict
233
+
234
+ @warnings << message
235
+ end
236
+
237
+ def field_data(f)
238
+ data = f[:data].to_s
239
+ if f[:hex]
240
+ hx = Regexp.escape(f[:hex_char])
241
+ data = data.gsub(/#{hx}([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
242
+ data.force_encoding(@encoding.to_i >= 28 ? Encoding::UTF_8 : Encoding::ISO_8859_1)
243
+ data = data.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
244
+ end
245
+ data
246
+ end
247
+
248
+ # ---- text ---------------------------------------------------------------
249
+
250
+ def draw_text(f)
251
+ font = f[:font] || @default_font
252
+ face = Font.face_for(font[:name], font[:h], font[:w])
253
+ rotation = f[:orientation] || font[:orientation] || @fw
254
+ text = field_data(f)
255
+ return if text.empty?
256
+
257
+ lines = if f[:block]
258
+ wrap_block(text, f[:block], face)
259
+ else
260
+ [text.gsub("\\&", " ")]
261
+ end
262
+
263
+ line_h = Font.line_height(face) + (f[:block] ? f[:block][:line_gap] : 0)
264
+ max_w = lines.map { |l| Font.measure(l, face) }.max || 0
265
+ block_w = f[:block] ? [f[:block][:width], max_w].max : max_w
266
+ bitmap = Canvas.new([block_w, 1].max, [lines.length * line_h, 1].max)
267
+ lines.each_with_index do |line, li|
268
+ line_w = Font.measure(line, face)
269
+ x = case f[:block] && f[:block][:just]
270
+ when "C" then (block_w - line_w) / 2
271
+ when "R" then block_w - line_w
272
+ else 0
273
+ end
274
+ Font.draw_line(bitmap, [x, 0].max, li * line_h, line, face)
275
+ end
276
+
277
+ x = f[:x]
278
+ y = f[:y]
279
+ if f[:typeset]
280
+ rw, rh = Canvas.rotated_size(bitmap.width, bitmap.height, rotation)
281
+ y -= rh
282
+ rw # unused; kept for clarity
283
+ end
284
+ @canvas.blit(bitmap, x, y, rotation: rotation, mode: f[:reverse] ? :xor : :paint)
285
+ @dirty = true
286
+ end
287
+
288
+ def wrap_block(text, block, face)
289
+ max_w = block[:width].positive? ? block[:width] : Font.measure(text, face)
290
+ lines = []
291
+ text.split("\\&").each do |paragraph|
292
+ words = paragraph.split(/ +/)
293
+ current = +""
294
+ words.each do |word|
295
+ candidate = current.empty? ? word : "#{current} #{word}"
296
+ if Font.measure(candidate, face) <= max_w
297
+ current = candidate
298
+ else
299
+ lines << current unless current.empty?
300
+ # hard-break words wider than the block
301
+ while Font.measure(word, face) > max_w && word.length > 1
302
+ head = word.length
303
+ head -= 1 while head > 1 && Font.measure(word[0, head], face) > max_w
304
+ lines << word[0, head]
305
+ word = word[head..]
306
+ end
307
+ current = word
308
+ end
309
+ end
310
+ lines << current
311
+ end
312
+ lines.take(block[:lines])
313
+ end
314
+
315
+ # ---- barcodes -----------------------------------------------------------
316
+
317
+ def draw_barcode(f)
318
+ bc = f[:barcode]
319
+ rotation = f[:orientation] || bc[:orientation] || @fw
320
+ data = field_data(f)
321
+ return if data.empty?
322
+
323
+ bitmap = case bc[:type]
324
+ when :code128 then linear_bitmap(code128_widths(data, bc), bc, data)
325
+ when :code39 then two_width_bitmap(Barcode::Code39.encode(data, check_digit: bc[:check]), bc)
326
+ when :i2of5 then two_width_bitmap(Barcode::Interleaved2of5.encode(data, check_digit: bc[:check]), bc)
327
+ when :pdf417 then pdf417_bitmap(data, bc)
328
+ when :qr then qr_bitmap(data, bc)
329
+ end
330
+ return unless bitmap
331
+
332
+ x = f[:x]
333
+ y = f[:y]
334
+ if f[:typeset]
335
+ _, rh = Canvas.rotated_size(bitmap.width, bitmap.height, rotation)
336
+ y -= rh
337
+ end
338
+ @canvas.blit(bitmap, x, y, rotation: rotation, mode: f[:reverse] ? :xor : :paint)
339
+ @dirty = true
340
+ end
341
+
342
+ def code128_widths(data, bc)
343
+ result = Barcode::Code128.encode(data, mode: bc[:mode], ucc_check: bc[:check])
344
+ modules = Barcode::Code128.modules(result.values)
345
+ widths = modules.each_char.map.with_index { |c, i| [c.to_i * @by[:module], i.even?] }
346
+ [widths, result.interpretation]
347
+ end
348
+
349
+ def linear_bitmap((widths, interp), bc, _data)
350
+ height = bc[:height] || @by[:height]
351
+ height = @by[:height] if height <= 0
352
+ total_w = widths.sum { |w, _| w }
353
+ compose_linear(widths, total_w, height, bc, interp)
354
+ end
355
+
356
+ def two_width_bitmap(result, bc)
357
+ narrow = @by[:module]
358
+ wide = (@by[:ratio] * narrow).round
359
+ widths = result.elements.map { |kind, bar| [kind == :wide ? wide : narrow, bar] }
360
+ height = bc[:height] || @by[:height]
361
+ height = @by[:height] if height <= 0
362
+ total_w = widths.sum { |w, _| w }
363
+ compose_linear(widths, total_w, height, bc, result.interpretation)
364
+ end
365
+
366
+ def compose_linear(widths, total_w, height, bc, interp)
367
+ show_interp = bc[:interp] && !interp.to_s.empty?
368
+ face = interpretation_face(interp, total_w)
369
+ interp_h = Font.line_height(face) + 3
370
+ full_h = height + (show_interp ? interp_h : 0)
371
+ bitmap = Canvas.new([total_w, 1].max, full_h)
372
+
373
+ bars_y = show_interp && bc[:above] ? interp_h : 0
374
+ x = 0
375
+ widths.each do |w, bar|
376
+ bitmap.fill_rect(x, bars_y, w, height) if bar
377
+ x += w
378
+ end
379
+
380
+ if show_interp
381
+ text_y = bc[:above] ? 0 : height + 1
382
+ text_x = [(total_w - Font.measure(interp, face)) / 2, 0].max
383
+ Font.draw_line(bitmap, text_x, text_y, interp, face)
384
+ end
385
+ bitmap
386
+ end
387
+
388
+ def interpretation_face(interp, total_w)
389
+ size = [@dpmm * 2, 10].max # ~2 mm tall interpretation line
390
+ face = Font.face_for("0", size, size)
391
+ while Font.measure(interp, face) > total_w && size > 5
392
+ size -= 1
393
+ face = Font.face_for("0", size, size)
394
+ end
395
+ face
396
+ end
397
+
398
+ def pdf417_bitmap(data, bc)
399
+ symbol = Barcode::Pdf417.encode(
400
+ data.b, security: bc[:security], columns: bc[:columns],
401
+ rows: bc[:rows], truncated: bc[:truncate]
402
+ )
403
+ mod_w = @by[:module]
404
+ row_h = bc[:row_height]
405
+ row_h = [mod_w * 3, 4].max if row_h.nil? || row_h < 1
406
+ width = symbol.patterns.first.sum { |_, count| count } * mod_w
407
+ bitmap = Canvas.new(width, symbol.rows * row_h)
408
+ symbol.patterns.each_with_index do |row, row_no|
409
+ x = 0
410
+ y = row_no * row_h
411
+ row.each do |pattern, count|
412
+ (count - 1).downto(0) do |bit|
413
+ bitmap.fill_rect(x, y, mod_w, row_h) if (pattern >> bit) & 1 == 1
414
+ x += mod_w
415
+ end
416
+ end
417
+ end
418
+ bitmap
419
+ end
420
+
421
+ def qr_bitmap(data, bc)
422
+ result = Barcode::Qr.encode(data, default_ecc: bc[:ecc])
423
+ mag = bc[:magnification].clamp(1, 10)
424
+ size = result.modules.length
425
+ bitmap = Canvas.new(size * mag, size * mag)
426
+ result.modules.each_with_index do |row, ry|
427
+ row.each_with_index do |mod, rx|
428
+ bitmap.fill_rect(rx * mag, ry * mag, mag, mag) if mod
429
+ end
430
+ end
431
+ bitmap
432
+ end
433
+
434
+ def default_qr_magnification
435
+ case @dpmm
436
+ when 0..6 then 1
437
+ when 7..8 then 2
438
+ when 9..14 then 3
439
+ else 6
440
+ end
441
+ end
442
+
443
+ # ---- graphics -----------------------------------------------------------
444
+
445
+ def draw_graphic(f)
446
+ g = f[:graphic]
447
+ mode = if f[:reverse]
448
+ :xor
449
+ else
450
+ g[:color] == :white ? :erase : :paint
451
+ end
452
+
453
+ bitmap = case g[:type]
454
+ when :box then box_bitmap(g)
455
+ when :circle then circle_bitmap(g)
456
+ when :diagonal then diagonal_bitmap(g)
457
+ when :gf then g[:canvas]
458
+ when :image then image_bitmap(g)
459
+ end
460
+ return unless bitmap
461
+
462
+ y = f[:y]
463
+ y -= bitmap.height if f[:typeset]
464
+ @canvas.blit(bitmap, f[:x], y, rotation: :n, mode: mode)
465
+ @dirty = true
466
+ end
467
+
468
+ def box_bitmap(g)
469
+ t = [g[:t], 1].max
470
+ w = [g[:w], t].max
471
+ h = [g[:h], t].max
472
+ bitmap = Canvas.new(w, h)
473
+ if g[:round].to_i.positive?
474
+ rounded_box(bitmap, w, h, t, g[:round].to_i.clamp(0, 8))
475
+ else
476
+ bitmap.fill_rect(0, 0, w, t)
477
+ bitmap.fill_rect(0, h - t, w, t)
478
+ bitmap.fill_rect(0, 0, t, h)
479
+ bitmap.fill_rect(w - t, 0, t, h)
480
+ end
481
+ bitmap
482
+ end
483
+
484
+ def rounded_box(bitmap, w, h, t, rounding)
485
+ radius = (rounding * [w, h].min) / 16
486
+ (0...h).each do |y|
487
+ (0...w).each do |x|
488
+ bitmap.set(x, y) if rounded_border?(x, y, w, h, t, radius)
489
+ end
490
+ end
491
+ end
492
+
493
+ def rounded_border?(x, y, w, h, t, r)
494
+ inside_rounded?(x, y, w, h, r) && !inside_rounded?(x - t, y - t, w - 2 * t, h - 2 * t, [r - t, 0].max)
495
+ end
496
+
497
+ def inside_rounded?(x, y, w, h, r)
498
+ return false if x.negative? || y.negative? || x >= w || y >= h
499
+ return true if r <= 0
500
+
501
+ cx = x < r ? r : (x >= w - r ? w - r - 1 : nil)
502
+ cy = y < r ? r : (y >= h - r ? h - r - 1 : nil)
503
+ return true if cx.nil? || cy.nil?
504
+
505
+ (x - cx)**2 + (y - cy)**2 <= r * r
506
+ end
507
+
508
+ def circle_bitmap(g)
509
+ d = [g[:d], 3].max
510
+ t = g[:t].clamp(1, d / 2 + 1)
511
+ bitmap = Canvas.new(d, d)
512
+ r_out = d / 2.0
513
+ r_in = r_out - t
514
+ cx = cy = (d - 1) / 2.0
515
+ (0...d).each do |y|
516
+ (0...d).each do |x|
517
+ dist2 = (x - cx)**2 + (y - cy)**2
518
+ bitmap.set(x, y) if dist2 <= r_out**2 && dist2 >= r_in**2 * (r_in.positive? ? 1 : 0)
519
+ end
520
+ end
521
+ bitmap
522
+ end
523
+
524
+ def diagonal_bitmap(g)
525
+ w = [g[:w], 3].max
526
+ h = [g[:h], 3].max
527
+ t = [g[:t], 1].max
528
+ bitmap = Canvas.new(w, h)
529
+ (0...w).each do |x|
530
+ y = g[:dir] == "L" ? (x * (h - 1) / [w - 1, 1].max) : (h - 1 - x * (h - 1) / [w - 1, 1].max)
531
+ (0...t).each do |dy|
532
+ bitmap.set(x, y + dy - t / 2)
533
+ bitmap.set(x + 1, y + dy - t / 2) if x + 1 < w # thicken against aliasing gaps
534
+ end
535
+ end
536
+ bitmap
537
+ end
538
+
539
+ def parse_gf(params)
540
+ # ^GFa,b,c,d,data - data may itself contain commas only for binary,
541
+ # so split just the first four fields.
542
+ parts = params.split(",", 5)
543
+ _format = (parts[0] || "A").strip.upcase
544
+ total = parts[2].to_i
545
+ per_row = parts[3].to_i
546
+ data = parts[4].to_s
547
+ { type: :gf, canvas: GraphicField.decode(data, per_row, total) }
548
+ end
549
+
550
+ def store_dg(params)
551
+ parts = params.split(",", 4)
552
+ name = normalize_image_name(parts[0])
553
+ total = parts[1].to_i
554
+ per_row = parts[2].to_i
555
+ data = parts[3].to_s
556
+ @images[name] = GraphicField.decode(data, per_row, total)
557
+ end
558
+
559
+ def image_bitmap(g)
560
+ src = @images[g[:name]]
561
+ unless src
562
+ problem("^XG references missing image #{g[:name]}")
563
+ return nil
564
+ end
565
+ return src if g[:mx] == 1 && g[:my] == 1
566
+
567
+ scaled = Canvas.new(src.width * g[:mx], src.height * g[:my])
568
+ (0...src.height).each do |y|
569
+ (0...src.width).each do |x|
570
+ scaled.fill_rect(x * g[:mx], y * g[:my], g[:mx], g[:my]) if src.get(x, y)
571
+ end
572
+ end
573
+ scaled
574
+ end
575
+
576
+ def normalize_image_name(raw)
577
+ name = raw.to_s.strip.upcase
578
+ name = "R:#{name}" unless name.include?(":")
579
+ name += ".GRF" unless name.include?(".")
580
+ name
581
+ end
582
+
583
+ # ---- misc ---------------------------------------------------------------
584
+
585
+ def resize_canvas(width: nil, height: nil)
586
+ w = width || @canvas.width
587
+ h = height || @canvas.height
588
+ return if w == @canvas.width && h == @canvas.height
589
+
590
+ fresh = Canvas.new(w, h)
591
+ fresh.blit(@canvas, 0, 0) if dirty?
592
+ @canvas = fresh
593
+ end
594
+
595
+ def font_from_params(p)
596
+ name = p[0].to_s[0] || "0"
597
+ { name: name, orientation: orient(p[0].to_s[1]), h: int(p[1], nil), w: int(p[2], nil) }
598
+ end
599
+
600
+ def orient(val)
601
+ case val.to_s.upcase
602
+ when "R" then :r
603
+ when "I" then :i
604
+ when "B" then :b
605
+ when "N" then :n
606
+ end
607
+ end
608
+
609
+ def color(val)
610
+ val.to_s.upcase == "W" ? :white : :black
611
+ end
612
+
613
+ def yn(val, default)
614
+ return default if val.nil? || val.to_s.empty?
615
+
616
+ val.to_s.upcase == "Y"
617
+ end
618
+
619
+ def int(val, default)
620
+ return default if val.nil? || val.to_s.strip.empty?
621
+
622
+ Integer(val.to_s.strip, 10)
623
+ rescue ArgumentError
624
+ default
625
+ end
626
+ end
627
+ end