diff --git a/README.md b/README.md index c5c1f98..51716ae 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,12 @@ Prawn::Document.new(background_pdf: "letterhead.pdf") do end ``` -QR codes in pure Ruby, drawn as vectors: one path and one fill per code, read back by zbar in CI: +QR codes and barcodes (Code 128, EAN-13, EAN-8, Code 39, ITF-14) in pure Ruby, drawn as vectors: one fill per +code, read back by zbar in CI: ```ruby qr_code "https://example.com/pay/INV-2026-0001", at: [400, 700], size: 80, level: :m +barcode "0123456789012", type: :ean13, width: 120, height: 40, text: true ``` Loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they share it in diff --git a/docs/guide.html b/docs/guide.html index a542134..6469467 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -42,7 +42,7 @@

Guide

  • SVG
  • Fonts
  • Images
  • -
  • QR codes
  • +
  • QR codes and barcodes
  • PDF/A and Factur-X
  • Output
  • Servers that fork
  • @@ -235,11 +235,14 @@

    Pages of other PDFs

    -

    QR codes

    +

    QR codes and barcodes

    A QR code is drawn as vector rectangles, one path and one fill, so it stays sharp at any size and a sheet of labels stays small. The encoder is pure Ruby, with no dependency: every version and level of error correction, numeric, alphanumeric and byte modes, and UTF-8 text announced to scanners.

    qr_code "https://example.com/pay/INV-2026-0001", at: [400, 700], size: 80, level: :m

    size is the width of the whole symbol, its quiet zone of four modules included; module_size gives the width of one module instead (2 points unless told). level is :l, :m (the default), :q or :h, and the smallest version that holds the text at that level is chosen; text too long for any raises ArgumentError. Without at, the code is placed as an image is, with position and vposition. quiet_zone: false leaves the margin to the layout, and color sets the colour of the modules. In Gamba’s own API, qr_code is a box, with size or module_size, level, quiet_zone, color and align.

    +

    Linear barcodes are drawn the same way, one fill per symbol: Code 128, with its sets A, B and C chosen as the text goes, EAN-13, EAN-8, Code 39 and ITF-14. A check digit left out is added, and one given is checked.

    +
    barcode "0123456789012", type: :ean13, width: 120, height: 40, text: true
    +

    width counts the quiet zones, or module_size sets the narrowest bar (2 points unless told); height is the bars’, 50 modules unless told. text: true writes the encoded text under the bars, check digit included, at text_size (8 modules unless told). at, position, vposition, quiet_zone and color work as for QR codes, and in Gamba’s own API barcode is a box too.

    diff --git a/lib/gamba/barcodes.rb b/lib/gamba/barcodes.rb index 53bd3ba..3bdd5ad 100644 --- a/lib/gamba/barcodes.rb +++ b/lib/gamba/barcodes.rb @@ -4,6 +4,7 @@ module Gamba # Symbologies encoded in pure Ruby, as grids of modules or runs of bars that Prawn::Barcodes draws. module Barcodes autoload :QRCode, ::File.expand_path("barcodes/qr_code", __dir__) + autoload :Linear, ::File.expand_path("barcodes/linear", __dir__) end end @@ -15,6 +16,11 @@ module Barcodes # The light margin scanners need around a QR code, in modules (ISO/IEC 18004 §6.3.8). QR_QUIET_ZONE_MODULES = 4 DEFAULT_MODULE_SIZE = 2 + BARCODE_OPTIONS = %i[type at position vposition width module_size height text text_size color quiet_zone].freeze + # Bars this many modules high unless told, and their digits this many modules high. + DEFAULT_BAR_HEIGHT_MODULES = 50 + TEXT_SIZE_MODULES = 8 + TEXT_GAP_MODULES = 2 # A QR code of the text, from the top left corner at `at`, or placed as an image is: size is its width, quiet # zone included, or module_size the width of one module. level: :l, :m, :q or :h. Returns the symbol. @@ -25,6 +31,49 @@ def qr_code(text, options = {}) symbol end + # A linear barcode of the data, type :code128, :ean13, :ean8, :code39 or :itf14, from the top left corner at + # `at`, or placed as an image is. width counts the quiet zones, or module_size sets the narrowest bar; height is + # the bars'. text: true writes the encoded text under them, check digit included. Returns the symbol. + def barcode(data, options = {}) + Prawn.verify_options(BARCODE_OPTIONS, options) + raise ArgumentError, "barcode needs a type: :code128, :ean13, :ean8, :code39 or :itf14" unless options[:type] + + symbol = Gamba::Barcodes::Linear.new(data, type: options[:type]) + draw_barcode(symbol, options) + symbol + end + + # A symbol already encoded, with the options of barcode but type. + def draw_barcode(symbol, options) + quiet_left, module_size, bar_height, text_size, width, height = Barcodes.linear_dimensions(symbol, options) + if options[:at] + left, top = map_to_absolute(options[:at]) + else + left, top = image_position(width, height, options.dup) + move_text_position(height) + end + bars_left = left + (quiet_left * module_size) + save_graphics_state do + fill_color(options.fetch(:color, "000000")) + add_content("#{bar_path(symbol, bars_left, top - bar_height, module_size, bar_height)}f") + if text_size + text_box symbol.text, at: [bars_left - bounds.absolute_left, top - bar_height - (TEXT_GAP_MODULES * module_size) - bounds.absolute_bottom], + width: symbol.length * module_size, height: text_size * 1.5, size: text_size, align: :center, overflow: :shrink_to_fit + end + end + end + + # The left quiet zone in modules, the module, bar and text sizes, and the whole symbol's width and height. + def self.linear_dimensions(symbol, options) + quiet_left, quiet_right = options.fetch(:quiet_zone, true) ? symbol.quiet_zones : [0, 0] + modules = quiet_left + symbol.length + quiet_right + module_size = options[:width] ? options[:width] / modules.to_f : options.fetch(:module_size, DEFAULT_MODULE_SIZE) + bar_height = options[:height] || (DEFAULT_BAR_HEIGHT_MODULES * module_size) + text_size = options[:text_size] || (TEXT_SIZE_MODULES * module_size) if options[:text] + height = bar_height + (text_size ? (TEXT_GAP_MODULES * module_size) + text_size : 0) + [quiet_left, module_size, bar_height, text_size, modules * module_size, height] + end + # A symbol already encoded, with the options of qr_code but level. def draw_qr_code(symbol, options) quiet_zone = options.fetch(:quiet_zone, true) ? QR_QUIET_ZONE_MODULES : 0 @@ -45,6 +94,16 @@ def draw_qr_code(symbol, options) private + def bar_path(symbol, left, bottom, module_size, height) + path = +"" + x = left + symbol.widths.each_with_index do |width, index| + path << PDF::Core.real_params([x, bottom, width * module_size, height]) << " re\n" if index.even? + x += width * module_size + end + path + end + # Dark modules side by side on a row are one rectangle of the path. def module_path(symbol, left, top, module_size) path = +"" diff --git a/lib/gamba/barcodes/linear.rb b/lib/gamba/barcodes/linear.rb new file mode 100644 index 0000000..1b80691 --- /dev/null +++ b/lib/gamba/barcodes/linear.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Gamba + module Barcodes + # A linear barcode as the widths of its bars and spaces, in modules, from the first bar: Code 128 (sets A, B + # and C chosen as the text goes), EAN-13, EAN-8, Code 39 and ITF-14. Check digits are added when left out, + # and checked when given. + class Linear + TYPES = %i[code128 ean13 ean8 code39 itf14].freeze + # Light margins scanners need, in modules, before and after the bars. + QUIET_ZONES = {code128: [10, 10], ean13: [11, 7], ean8: [7, 7], code39: [10, 10], itf14: [10, 10]}.freeze + WIDE = 3 + + CODE128_PATTERNS = %w[ + 212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 221312 231212 112232 122132 122231 113222 + 123122 123221 223211 221132 221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 212123 212321 + 232121 111323 131123 131321 112313 132113 132311 211313 231113 231311 112133 112331 132131 113123 113321 133121 + 313121 211331 231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 314111 221411 431111 111224 + 111422 121124 121421 141122 141221 112214 112412 122114 122411 142112 142211 241211 221114 413111 241112 134111 + 111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 214121 412121 111143 111341 131141 114113 + 114311 411113 411311 113141 114131 311141 411131 211412 211214 211232 2331112 + ].map { |pattern| pattern.each_char.map(&:to_i).freeze }.freeze + CODE128_START = {a: 103, b: 104, c: 105}.freeze + CODE128_SWITCH = {a: 101, b: 100, c: 99}.freeze + CODE128_STOP = 106 + # Digits worth a switch to set C: fewer cost more than they save. + CODE128_DIGITS_FOR_C = 4 + + EAN_L = %w[0001101 0011001 0010011 0111101 0100011 0110001 0101111 0111011 0110111 0001011].freeze + EAN_G = %w[0100111 0110011 0011011 0100001 0011101 0111001 0000101 0010001 0001001 0010111].freeze + EAN_R = %w[1110010 1100110 1101100 1000010 1011100 1001110 1010000 1000100 1001000 1110100].freeze + # Which left digits of an EAN-13 take the G patterns, by its first digit, which no pattern encodes. + EAN13_PARITY = %w[LLLLLL LLGLGG LLGGLG LLGGGL LGLLGG LGGLLG LGGGLL LGLGLG LGLGGL LGGLGL].freeze + + CODE39 = { + "0" => "nnnwwnwnn", "1" => "wnnwnnnnw", "2" => "nnwwnnnnw", "3" => "wnwwnnnnn", "4" => "nnnwwnnnw", + "5" => "wnnwwnnnn", "6" => "nnwwwnnnn", "7" => "nnnwnnwnw", "8" => "wnnwnnwnn", "9" => "nnwwnnwnn", + "A" => "wnnnnwnnw", "B" => "nnwnnwnnw", "C" => "wnwnnwnnn", "D" => "nnnnwwnnw", "E" => "wnnnwwnnn", + "F" => "nnwnwwnnn", "G" => "nnnnnwwnw", "H" => "wnnnnwwnn", "I" => "nnwnnwwnn", "J" => "nnnnwwwnn", + "K" => "wnnnnnnww", "L" => "nnwnnnnww", "M" => "wnwnnnnwn", "N" => "nnnnwnnww", "O" => "wnnnwnnwn", + "P" => "nnwnwnnwn", "Q" => "nnnnnnwww", "R" => "wnnnnnwwn", "S" => "nnwnnnwwn", "T" => "nnnnwnwwn", + "U" => "wwnnnnnnw", "V" => "nwwnnnnnw", "W" => "wwwnnnnnn", "X" => "nwnnwnnnw", "Y" => "wwnnwnnnn", + "Z" => "nwwnwnnnn", "-" => "nwnnnnwnw", "." => "wwnnnnwnn", " " => "nwwnnnwnn", "$" => "nwnwnwnnn", + "/" => "nwnwnnnwn", "+" => "nwnnnwnwn", "%" => "nnnwnwnwn", "*" => "nwnnwnwnn" + }.transform_values { |pattern| pattern.each_char.map { (_1 == "w") ? WIDE : 1 }.freeze }.freeze + + ITF = %w[nnwwn wnnnw nwnnw wwnnn nnwnw wnwnn nwwnn nnnww wnnwn nwnwn].map do |pattern| + pattern.each_char.map { (_1 == "w") ? WIDE : 1 }.freeze + end.freeze + ITF_START = [1, 1, 1, 1].freeze + ITF_STOP = [WIDE, 1, 1].freeze + + attr_reader :type, :widths, :text + + def initialize(data, type:) + raise ArgumentError, "type is #{type.inspect}: accepted types are #{TYPES.map(&:inspect).join(", ")}" unless TYPES.include?(type) + + @type = type + @text = data.to_s + @widths = send(type) + end + + def quiet_zones + QUIET_ZONES.fetch(@type) + end + + # Modules from the first bar to the end of the last. + def length + @widths.sum + end + + private + + def code128 + raise ArgumentError, "Code 128 encodes ASCII: #{@text.inspect} is not" unless @text.ascii_only? + raise ArgumentError, "Code 128 needs at least one character" if @text.empty? + + values = code128_values(@text) + checksum = values.each_with_index.sum { |value, position| value * [position, 1].max } % 103 + (values + [checksum, CODE128_STOP]).flat_map { CODE128_PATTERNS[_1] } + end + + # Set C for runs of digits long enough to pay for the switch, A for control characters, B otherwise. + def code128_values(text) + values = [] + set = nil + position = 0 + while position < text.size + digits = text[position..][/\A\d+/].to_s.size + if digits >= CODE128_DIGITS_FOR_C || (digits >= 2 && digits == text.size - position && (set.nil? || set == :c)) + if digits.odd? + set = code128_switch(values, set, code128_set_for(text[position])) + values << code128_value(text[position], set) + position += 1 + digits -= 1 + end + set = code128_switch(values, set, :c) + text[position, digits].scan(/\d\d/) { values << _1.to_i } + position += digits + else + character = text[position] + set = code128_switch(values, set, code128_set_for(character)) unless set && set != :c && code128_value(character, set) + values << code128_value(character, set) + position += 1 + end + end + values + end + + def code128_set_for(character) + (character.ord < 32) ? :a : :b + end + + def code128_switch(values, current, wanted) + return current if current == wanted + + values << (current.nil? ? CODE128_START : CODE128_SWITCH).fetch(wanted) + wanted + end + + def code128_value(character, set) + code = character.ord + if set == :a + return code + 64 if code < 32 + + code - 32 if code < 96 + elsif set == :b + code - 32 if code >= 32 + end + end + + def ean13 + digits = check_digits(13) + parity = EAN13_PARITY[digits.first] + left = digits[1, 6].each_with_index.map { |digit, index| ((parity[index] == "G") ? EAN_G : EAN_L)[digit] } + modules("101" + left.join + "01010" + digits[7, 6].map { EAN_R[_1] }.join + "101") + end + + def ean8 + digits = check_digits(8) + modules("101" + digits[0, 4].map { EAN_L[_1] }.join + "01010" + digits[4, 4].map { EAN_R[_1] }.join + "101") + end + + def code39 + text = @text = @text.upcase + unknown = text.each_char.reject { CODE39.key?(_1) && _1 != "*" } + raise ArgumentError, "Code 39 encodes 0-9, A-Z, space and - . $ / + %: #{unknown.uniq.join.inspect} is not among them" unless unknown.empty? + + "*#{text}*".each_char.flat_map { CODE39.fetch(_1) + [1] }[0...-1] + end + + def itf14 + digits = check_digits(14) + pairs = digits.each_slice(2).flat_map { |bars, spaces| ITF[bars].zip(ITF[spaces]).flatten! } + ITF_START + pairs + ITF_STOP + end + + # The digits of a GTIN-like number of `count` digits, its check digit added when it lacks one: weights 3 and 1 + # from the right (GS1 General Specifications §7.9). + def check_digits(count) + raise ArgumentError, "#{@type.to_s.upcase} encodes #{count - 1} digits and a check digit: #{@text.inspect} is not that" unless @text.match?(/\A\d{#{count - 1},#{count}}\z/) + + digits = @text.each_char.map(&:to_i) + check = check_digit(digits.first(count - 1)) + raise ArgumentError, "#{@text} ends in #{digits.last}, but its check digit is #{check}" if digits.size == count && digits.last != check + + @text = (digits.first(count - 1) + [check]).join + digits.first(count - 1) + [check] + end + + def check_digit(digits) + sum = digits.reverse.each_with_index.sum { |digit, index| index.even? ? digit * 3 : digit } + (10 - (sum % 10)) % 10 + end + + # "1011…" as widths of runs, from the first bar. + def modules(bits) + bits.scan(/1+|0+/).map(&:size) + end + end + end +end diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb index 50e9b83..a12bf0b 100644 --- a/lib/gamba/native/layout.rb +++ b/lib/gamba/native/layout.rb @@ -328,6 +328,43 @@ def description end end + # A linear barcode as a box: as wide as told, quiet zones included, or as many points per module as + # module_size says; never wider than its box. + class Barcode < Node + OPTIONS = (Node::OPTIONS + %i[type module_size height text text_size color quiet_zone align]).freeze + + def initialize(data, type:, align: :left, **options) + raise ArgumentError, "align is #{align.inspect}: accepted alignments are #{Image::ALIGNMENTS.map(&:inspect).join(", ")}" unless Image::ALIGNMENTS.include?(align) + + super(**options.slice(*Node::OPTIONS)) + @symbol = Gamba::Barcodes::Linear.new(data, type:) + @options = options.except(*Node::OPTIONS) + @align = align + end + + def paint(prawn, left, top, width) + options = drawn(width) + offset = {left: 0, center: (width - options[:width]) / 2.0, right: width - options[:width]}.fetch(@align) + prawn.draw_barcode(@symbol, at: relative(prawn, left + offset, top), **options) + end + + private + + def measure(_prawn, width) + Prawn::Barcodes.linear_dimensions(@symbol, drawn(width)).last + end + + # The options to draw it with: its width at most the box's. + def drawn(available_width) + natural = Prawn::Barcodes.linear_dimensions(@symbol, {width: width}.compact.merge(@options))[4] + @options.merge(width: [natural, available_width].min) + end + + def description + "a barcode" + end + end + # What a document and its containers share: the calls that add a box. module Builder # Rows are hashes, or objects answering the columns' names. The block receives the Prawn::Table before it @@ -341,6 +378,11 @@ def image(source, **options) add(Image.new(source, **options)) end + def barcode(data, **options) + Styles.check_options(options, Barcode::OPTIONS) + add(Barcode.new(data, **options)) + end + def qr_code(text, **options) Styles.check_options(options, QRCode::OPTIONS) add(QRCode.new(text, **options)) diff --git a/test/barcode_test.rb b/test/barcode_test.rb new file mode 100644 index 0000000..e3308e9 --- /dev/null +++ b/test/barcode_test.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "open3" +require "prawn" + +# Linear barcodes drawn as vector bars, read back by zbar from the rendered page. +class BarcodeTest < Minitest::Test + SEED = Integer(ENV.fetch("BARCODE_SEED", "20261113")) + ZBAR_MISSING = "zbarimg is not installed: apt-get install zbar-tools or brew install zbar" + ZBAR_NAMES = {code128: "CODE-128", ean13: "EAN-13", ean8: "EAN-8", code39: "CODE-39", itf14: "I2/5"}.freeze + + def test_every_type_reads_back_as_written_check_digits_included + skip ZBAR_MISSING unless zbar? + + random = Random.new(SEED) + digits = ->(count) { Array.new(count) { random.rand(10) }.join } + samples = { + code128: ["Hello, World!", "1234567890", "A1234567B", "12345", "x\tY", "0", *Array.new(6) { Array.new(1 + random.rand(24)) { (32 + random.rand(95)).chr }.join }], + ean13: ["4006381333931", "400638133393", *Array.new(4) { digits.call(12) }], + ean8: ["96385074", "9638507", *Array.new(4) { digits.call(7) }], + code39: ["CODE 39-$/+%.", "alice", digits.call(10)], + itf14: ["15400141288763", "1540014128876", *Array.new(4) { digits.call(13) }] + } + samples.each do |type, texts| + texts.each do |text| + symbol = nil + pdf = Prawn::Document.new { symbol = barcode text, type:, module_size: 1.5 }.render + + assert_equal "#{ZBAR_NAMES.fetch(type)}:#{symbol.text}", read_back(pdf), "#{type} #{text.inspect}" + end + end + end + + def test_check_digits_are_added_and_checked + assert_equal "4006381333931", Gamba::Barcodes::Linear.new("400638133393", type: :ean13).text + assert_equal "96385074", Gamba::Barcodes::Linear.new("9638507", type: :ean8).text + assert_equal "15400141288763", Gamba::Barcodes::Linear.new("1540014128876", type: :itf14).text + assert_equal "4006381333932 ends in 2, but its check digit is 1", assert_raises(ArgumentError) { Gamba::Barcodes::Linear.new("4006381333932", type: :ean13) }.message + end + + def test_sets_switch_where_they_save_room + assert_equal (6 * 11) + 13, Gamba::Barcodes::Linear.new("12345678", type: :code128).length, "start C, four pairs and the check, then the stop" + assert_equal Gamba::Barcodes::Linear.new("ab", type: :code128).length, Gamba::Barcodes::Linear.new("12", type: :code128).length + 11 + end + + def test_contents_a_type_cannot_hold_raise + assert_raises(ArgumentError) { Gamba::Barcodes::Linear.new("café", type: :code128) } + assert_raises(ArgumentError) { Gamba::Barcodes::Linear.new("", type: :code128) } + assert_raises(ArgumentError) { Gamba::Barcodes::Linear.new("12345", type: :ean13) } + assert_raises(ArgumentError) { Gamba::Barcodes::Linear.new("a_b", type: :code39) } + assert_raises(ArgumentError) { Gamba::Barcodes::Linear.new("1", type: :upc) } + assert_raises(ArgumentError) { Prawn::Document.new.barcode("1") } + end + + def test_a_symbol_is_one_fill_its_width_counting_the_quiet_zones + symbol = nil + document = Prawn::Document.new { symbol = barcode "4006381333931", type: :ean13, at: [0, 700], width: 113, height: 40 } + content = uncompressed(document.render)[/stream\n(.*?)endstream/m, 1] + bars = content.scan(/^([\d.]+) ([\d.]+) ([\d.]+) 40.0 re$/).map { |x, _y, width| [x.to_f, width.to_f] } + + assert_equal 113, symbol.quiet_zones.sum + symbol.length + assert_equal 1, content.scan(/^f$/).size + assert_equal 30, bars.size + assert_equal [36 + 11, 36 + 11 + 95], [bars.first.first, bars.last.sum] + end + + def test_the_text_goes_under_the_bars_and_the_cursor_below_it + document = Prawn::Document.new do + barcode "alice", type: :code128, module_size: 1, height: 30, text: true + end + cursor = document.cursor + glyphs = Tempfile.create(["barcode", ".pdf"]) do |file| + file.binmode + file.write(document.render) + file.flush + Oracle.text_layout(file.path).first.glyphs + end + + assert_equal "alice", glyphs.map(&:first).join + assert_equal [8.0], glyphs.map { _1[2] }.uniq + assert_in_delta 720 - 30 - 2 - 8, cursor, 0.001 + end + + def test_the_native_api_draws_a_barcode_as_a_box + skip ZBAR_MISSING unless zbar? + + document = Gamba.document do |yielded| + yielded.row do |row| + row.text "Parcel" + row.barcode "00012345678905", type: :itf14, width: 200, height: 50, align: :right + end + end + + assert_equal "I2/5:00012345678905", read_back(document.to_s) + end + + private + + def zbar? + system("zbarimg", "--version", out: File::NULL, err: File::NULL) + end + + def read_back(pdf) + Dir.mktmpdir do |directory| + File.binwrite(File.join(directory, "code.pdf"), pdf) + Oracle.mutool("draw", "-r", "150", "-o", File.join(directory, "page.png"), File.join(directory, "code.pdf")) + output, _status = Open3.capture2("zbarimg", "--quiet", "-Si25.enable", "-Scode39.enable", File.join(directory, "page.png"), err: File::NULL) + output.chomp + end + end + + def uncompressed(pdf) + Tempfile.create(["barcode", ".pdf"]) do |file| + file.binmode + file.write(pdf) + file.flush + Open3.capture2("qpdf", "--qdf", "--object-streams=disable", file.path, "-").first.b + end + end +end diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml index 76c8969..278d4da 100644 --- a/test/contract/gamba.yml +++ b/test/contract/gamba.yml @@ -6,6 +6,7 @@ Gamba: ".document": [keyrest options] ".eager_load!": [] Gamba::Document: + "#barcode": [req data, keyrest options] "#draw": [req component] "#page_break": [] "#prawn": [] @@ -25,6 +26,7 @@ Gamba::Document: "#write": [req destination] # What a stack, a row and a grid answer inside their block; the rest of the class is how they are measured and placed. Gamba::Layout::Container: + "#barcode": [req data, keyrest options] "#draw": [req component] "#grid": [keyreq columns, keyrest options] "#image": [req source, keyrest options]