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.
+
A DataMatrix, which carriers ask for on parcel labels, takes the same options but level: data_matrix "alice@example.com", size: 40 draws a square ECC 200 symbol, the smallest of its 24 sizes that holds the data, digits two to a codeword, with a quiet zone of one module.
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 3bdd5ad..280a1be 100644
--- a/lib/gamba/barcodes.rb
+++ b/lib/gamba/barcodes.rb
@@ -5,6 +5,7 @@ module Gamba
module Barcodes
autoload :QRCode, ::File.expand_path("barcodes/qr_code", __dir__)
autoload :Linear, ::File.expand_path("barcodes/linear", __dir__)
+ autoload :DataMatrix, ::File.expand_path("barcodes/data_matrix", __dir__)
end
end
@@ -13,8 +14,7 @@ module Prawn
# stays small.
module Barcodes
QR_OPTIONS = %i[at position vposition size module_size level quiet_zone color].freeze
- # The light margin scanners need around a QR code, in modules (ISO/IEC 18004 §6.3.8).
- QR_QUIET_ZONE_MODULES = 4
+ DATA_MATRIX_OPTIONS = (QR_OPTIONS - %i[level]).freeze
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.
@@ -27,7 +27,16 @@ module Barcodes
def qr_code(text, options = {})
Prawn.verify_options(QR_OPTIONS, options)
symbol = Gamba::Barcodes::QRCode.new(text, level: options.fetch(:level, :m))
- draw_qr_code(symbol, options)
+ draw_matrix_code(symbol, options)
+ symbol
+ end
+
+ # A square DataMatrix ECC 200 symbol of the data, the smallest that holds it, with the options of qr_code but
+ # level: its error correction is fixed by its size. Returns the symbol.
+ def data_matrix(data, options = {})
+ Prawn.verify_options(DATA_MATRIX_OPTIONS, options)
+ symbol = Gamba::Barcodes::DataMatrix.new(data)
+ draw_matrix_code(symbol, options)
symbol
end
@@ -74,9 +83,9 @@ def self.linear_dimensions(symbol, options)
[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
+ # A QR code or a DataMatrix already encoded, with the options of qr_code but level.
+ def draw_matrix_code(symbol, options)
+ quiet_zone = options.fetch(:quiet_zone, true) ? symbol.quiet_zone : 0
modules = symbol.size + (2 * quiet_zone)
module_size = options[:size] ? options[:size] / modules.to_f : options.fetch(:module_size, DEFAULT_MODULE_SIZE)
width = modules * module_size
diff --git a/lib/gamba/barcodes/data_matrix.rb b/lib/gamba/barcodes/data_matrix.rb
new file mode 100644
index 0000000..b914a06
--- /dev/null
+++ b/lib/gamba/barcodes/data_matrix.rb
@@ -0,0 +1,204 @@
+# frozen_string_literal: true
+
+module Gamba
+ module Barcodes
+ # A square DataMatrix ECC 200 symbol (ISO/IEC 16022:2006) as a grid of modules: the smallest size that holds the
+ # data in ASCII encodation, digits two to a codeword, other bytes past 127 behind an upper shift.
+ class DataMatrix
+ # Square sizes: modules on a side, data and error correction codewords, data region side, interleaved blocks.
+ Size = Data.define(:modules, :data, :ecc, :region, :blocks)
+ SIZES = [
+ [10, 3, 5, 8, 1], [12, 5, 7, 10, 1], [14, 8, 10, 12, 1], [16, 12, 12, 14, 1], [18, 18, 14, 16, 1],
+ [20, 22, 18, 18, 1], [22, 30, 20, 20, 1], [24, 36, 24, 22, 1], [26, 44, 28, 24, 1], [32, 62, 36, 14, 1],
+ [36, 86, 42, 16, 1], [40, 114, 48, 18, 1], [44, 144, 56, 20, 1], [48, 174, 68, 22, 1], [52, 204, 84, 24, 2],
+ [64, 280, 112, 14, 2], [72, 368, 144, 16, 4], [80, 456, 192, 18, 4], [88, 576, 224, 20, 4],
+ [96, 696, 272, 22, 4], [104, 816, 336, 24, 6], [120, 1050, 408, 18, 6], [132, 1304, 496, 20, 8],
+ [144, 1558, 620, 22, 10]
+ ].map { |values| Size.new(*values) }.freeze
+ # The light margin around the symbol, in modules (§7.7).
+ QUIET_ZONE = 1
+ PAD = 129
+ UPPER_SHIFT = 235
+ DIGIT_PAIR = 130
+ # GF(2^8) modulo x^8 + x^5 + x^3 + x^2 + 1, the field of ECC 200's Reed-Solomon codes.
+ EXPONENTS = Array.new(510).tap do |powers|
+ value = 1
+ 255.times do |exponent|
+ powers[exponent] = powers[exponent + 255] = value
+ value <<= 1
+ value ^= 0x12D if value > 0xFF
+ end
+ end.freeze
+ LOGARITHMS = Array.new(256).tap { |logarithms| 255.times { logarithms[EXPONENTS[_1]] = _1 } }.freeze
+
+ attr_reader :size
+
+ def initialize(data)
+ codewords = encode(data.to_s.b)
+ @format = SIZES.find { _1.data >= codewords.size } ||
+ raise(ArgumentError, "#{data.to_s.bytesize} bytes take #{codewords.size} codewords: a DataMatrix holds 1558 at most")
+ @size = @format.modules
+ codewords = pad(codewords)
+ mapping = place(interleaved(codewords))
+ @modules = draw(mapping)
+ end
+
+ def dark?(x, y)
+ @modules[y][x]
+ end
+
+ def quiet_zone
+ QUIET_ZONE
+ end
+
+ private
+
+ def encode(bytes)
+ codewords = []
+ bytes.scan(/\d\d|./mn) do |chunk|
+ if chunk.size == 2
+ codewords << (DIGIT_PAIR + chunk.to_i)
+ elsif chunk.ord < 128
+ codewords << (chunk.ord + 1)
+ else
+ codewords << UPPER_SHIFT << (chunk.ord - 127)
+ end
+ end
+ codewords
+ end
+
+ # The first pad is 129; the next ones are scrambled by their position, from 1 (§5.2.3).
+ def pad(codewords)
+ codewords << PAD if codewords.size < @format.data
+ while codewords.size < @format.data
+ value = PAD + (((149 * (codewords.size + 1)) % 253) + 1)
+ codewords << ((value > 254) ? value - 254 : value)
+ end
+ codewords
+ end
+
+ # Codewords dealt to the blocks in turn, each block's error correction dealt back the same way.
+ def interleaved(data)
+ blocks = @format.blocks
+ ecc_length = @format.ecc / blocks
+ divisor = divisor(ecc_length)
+ result = data.dup
+ blocks.times do |block|
+ remainder = remainder(data.select.with_index { |_, index| index % blocks == block }, divisor)
+ remainder.each_with_index { |codeword, index| result[data.size + block + (index * blocks)] = codeword }
+ end
+ result
+ end
+
+ # (x - a^1)(x - a^2)…(x - a^degree), highest degree first without its leading 1.
+ def divisor(degree)
+ coefficients = [1]
+ (1..degree).each do |root|
+ next_coefficients = Array.new(coefficients.size + 1, 0)
+ coefficients.each_with_index do |coefficient, index|
+ next_coefficients[index] ^= coefficient
+ next_coefficients[index + 1] ^= multiply(coefficient, EXPONENTS[root])
+ end
+ coefficients = next_coefficients
+ end
+ coefficients.drop(1)
+ end
+
+ def remainder(data, divisor)
+ result = Array.new(divisor.size, 0)
+ data.each do |codeword|
+ factor = codeword ^ result.shift
+ result << 0
+ divisor.each_with_index { |coefficient, index| result[index] ^= multiply(coefficient, factor) }
+ end
+ result
+ end
+
+ def multiply(x, y)
+ (x.zero? || y.zero?) ? 0 : EXPONENTS[LOGARITHMS[x] + LOGARITHMS[y]]
+ end
+
+ # The data regions put together, filled by the standard's diagonal placement (Annex F).
+ def place(codewords)
+ @rows = @columns = (@size / (@format.region + 2)) * @format.region
+ @mapping = Array.new(@rows * @columns)
+ @codewords = codewords
+ character = 0
+ row = 4
+ column = 0
+ loop do
+ corner(character += 1, CORNER_1) if row == @rows && column.zero?
+ corner(character += 1, CORNER_2) if row == @rows - 2 && column.zero? && @columns % 4 != 0
+ corner(character += 1, CORNER_3) if row == @rows - 2 && column.zero? && @columns % 8 == 4
+ corner(character += 1, CORNER_4) if row == @rows + 4 && column == 2 && (@columns % 8).zero?
+ loop do
+ utah(row, column, character += 1) if row < @rows && column >= 0 && @mapping[(row * @columns) + column].nil?
+ row -= 2
+ column += 2
+ break unless row >= 0 && column < @columns
+ end
+ row += 1
+ column += 3
+ loop do
+ utah(row, column, character += 1) if row >= 0 && column < @columns && @mapping[(row * @columns) + column].nil?
+ row += 2
+ column -= 2
+ break unless row < @rows && column >= 0
+ end
+ row += 3
+ column += 1
+ break unless row < @rows || column < @columns
+ end
+ @mapping[(@rows * @columns) - 1] = @mapping[(@rows * @columns) - @columns - 2] = true if @mapping.last.nil?
+ @mapping
+ end
+
+ # Corner shapes: [row, column] of bits 1 to 8, from the far edges as negative numbers count.
+ CORNER_1 = [[-1, 0], [-1, 1], [-1, 2], [0, -2], [0, -1], [1, -1], [2, -1], [3, -1]].freeze
+ CORNER_2 = [[-3, 0], [-2, 0], [-1, 0], [0, -4], [0, -3], [0, -2], [0, -1], [1, -1]].freeze
+ CORNER_3 = [[-3, 0], [-2, 0], [-1, 0], [0, -2], [0, -1], [1, -1], [2, -1], [3, -1]].freeze
+ CORNER_4 = [[-1, 0], [-1, -1], [0, -3], [0, -2], [0, -1], [1, -3], [1, -2], [1, -1]].freeze
+ UTAH = [[-2, -2], [-2, -1], [-1, -2], [-1, -1], [-1, 0], [0, -2], [0, -1], [0, 0]].freeze
+
+ def corner(character, shape)
+ shape.each_with_index do |(row, column), bit|
+ module_at(row.negative? ? @rows + row : row, column.negative? ? @columns + column : column, character, bit)
+ end
+ end
+
+ def utah(row, column, character)
+ UTAH.each_with_index { |(dy, dx), bit| module_at(row + dy, column + dx, character, bit) }
+ end
+
+ # Bit 0 is the codeword's most significant; positions off the edges wrap as the standard says.
+ def module_at(row, column, character, bit)
+ if row.negative?
+ row += @rows
+ column += 4 - ((@rows + 4) % 8)
+ end
+ if column.negative?
+ column += @columns
+ row += 4 - ((@columns + 4) % 8)
+ end
+ codeword = @codewords[character - 1]
+ @mapping[(row * @columns) + column] = !codeword.nil? && codeword[7 - bit] == 1
+ end
+
+ # Each data region framed by its finder: solid left and bottom edges, alternating top and right ones.
+ def draw(mapping)
+ side = @format.region
+ Array.new(@size) do |y|
+ Array.new(@size) do |x|
+ region_y, inner_y = y.divmod(side + 2)
+ region_x, inner_x = x.divmod(side + 2)
+ if inner_x.zero? || inner_y == side + 1 then true
+ elsif inner_y.zero? then inner_x.even?
+ elsif inner_x == side + 1 then inner_y.odd?
+ else mapping[(((region_y * side) + inner_y - 1) * @columns) + (region_x * side) + inner_x - 1]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gamba/barcodes/qr_code.rb b/lib/gamba/barcodes/qr_code.rb
index 02a4950..8863102 100644
--- a/lib/gamba/barcodes/qr_code.rb
+++ b/lib/gamba/barcodes/qr_code.rb
@@ -7,6 +7,8 @@ module Barcodes
# is not ASCII, and the mask the standard's penalty rules prefer.
class QRCode
LEVELS = %i[l m q h].freeze
+ # The light margin scanners need around the symbol, in modules (§6.3.8).
+ QUIET_ZONE = 4
# The two bits a level writes in the format information.
FORMAT_BITS = {l: 1, m: 0, q: 3, h: 2}.freeze
# Per version 1 to 40: error correction codewords in each block, then the number of blocks (tables 9).
@@ -80,6 +82,10 @@ def dark?(x, y)
@modules[y][x]
end
+ def quiet_zone
+ QUIET_ZONE
+ end
+
private
# The bits of the one segment that holds the text, in the smallest version where they fit.
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index a12bf0b..a45a80c 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -289,16 +289,16 @@ def image(prawn)
end
end
- # A QR code as a box: size wide, its quiet zone included, or as many points per module as module_size says;
- # never wider than its box.
- class QRCode < Node
- OPTIONS = (Node::OPTIONS + %i[size module_size level quiet_zone color align]).freeze
+ # A QR code or a DataMatrix as a box: size wide, its quiet zone included, or as many points per module as
+ # module_size says; never wider than its box.
+ class MatrixCode < Node
+ OPTIONS = (Node::OPTIONS + %i[size module_size quiet_zone color align]).freeze
- def initialize(text, size: nil, module_size: Prawn::Barcodes::DEFAULT_MODULE_SIZE, level: :m, quiet_zone: true, color: "000000", align: :left, **)
+ def initialize(symbol, size: nil, module_size: Prawn::Barcodes::DEFAULT_MODULE_SIZE, quiet_zone: true, color: "000000", align: :left, **)
raise ArgumentError, "align is #{align.inspect}: accepted alignments are #{Image::ALIGNMENTS.map(&:inspect).join(", ")}" unless Image::ALIGNMENTS.include?(align)
super(**)
- @symbol = Gamba::Barcodes::QRCode.new(text, level:)
+ @symbol = symbol
@size = size
@module_size = module_size
@quiet_zone = quiet_zone
@@ -309,7 +309,7 @@ def initialize(text, size: nil, module_size: Prawn::Barcodes::DEFAULT_MODULE_SIZ
def paint(prawn, left, top, width)
size = side(width)
offset = {left: 0, center: (width - size) / 2.0, right: width - size}.fetch(@align)
- prawn.draw_qr_code(@symbol, at: relative(prawn, left + offset, top), size:, quiet_zone: @quiet_zone, color: @color)
+ prawn.draw_matrix_code(@symbol, at: relative(prawn, left + offset, top), size:, quiet_zone: @quiet_zone, color: @color)
end
private
@@ -319,12 +319,12 @@ def measure(_prawn, width)
end
def side(available_width)
- modules = @symbol.size + (@quiet_zone ? 2 * Prawn::Barcodes::QR_QUIET_ZONE_MODULES : 0)
+ modules = @symbol.size + (@quiet_zone ? 2 * @symbol.quiet_zone : 0)
[@size || width || (modules * @module_size), available_width].min
end
def description
- "a QR code"
+ @symbol.is_a?(Gamba::Barcodes::QRCode) ? "a QR code" : "a DataMatrix"
end
end
@@ -383,9 +383,14 @@ def barcode(data, **options)
add(Barcode.new(data, **options))
end
- def qr_code(text, **options)
- Styles.check_options(options, QRCode::OPTIONS)
- add(QRCode.new(text, **options))
+ def qr_code(text, level: :m, **options)
+ Styles.check_options(options, MatrixCode::OPTIONS)
+ add(MatrixCode.new(Gamba::Barcodes::QRCode.new(text, level:), **options))
+ end
+
+ def data_matrix(data, **options)
+ Styles.check_options(options, MatrixCode::OPTIONS)
+ add(MatrixCode.new(Gamba::Barcodes::DataMatrix.new(data), **options))
end
# A page of another PDF (a path, an IO or its bytes), from 1, as a box sized like an image.
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index 278d4da..26b1108 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -7,6 +7,7 @@ Gamba:
".eager_load!": []
Gamba::Document:
"#barcode": [req data, keyrest options]
+ "#data_matrix": [req data, keyrest options]
"#draw": [req component]
"#page_break": []
"#prawn": []
@@ -17,7 +18,7 @@ Gamba::Document:
"#header": [key gap, block block]
"#image": [req source, keyrest options]
"#pdf_page": [req source, key page, keyrest options]
- "#qr_code": [req text, keyrest options]
+ "#qr_code": [req text, key level, keyrest options]
"#register_font": [req family, keyreq normal, key bold, key italic, key bold_italic, key features]
"#style": [req name, keyrest attributes]
"#table": [req rows, keyrest options, block styling]
@@ -27,11 +28,12 @@ Gamba::Document:
# 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]
+ "#data_matrix": [req data, keyrest options]
"#draw": [req component]
"#grid": [keyreq columns, keyrest options]
"#image": [req source, keyrest options]
"#pdf_page": [req source, key page, keyrest options]
- "#qr_code": [req text, keyrest options]
+ "#qr_code": [req text, key level, keyrest options]
"#row": [keyrest options]
"#stack": [keyrest options]
"#table": [req rows, keyrest options, block styling]
diff --git a/test/data_matrix_test.rb b/test/data_matrix_test.rb
new file mode 100644
index 0000000..df54f9f
--- /dev/null
+++ b/test/data_matrix_test.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "open3"
+require "prawn"
+
+# DataMatrix ECC 200 symbols drawn as vector rectangles, read back by libdmtx from the rendered page.
+class DataMatrixTest < Minitest::Test
+ SEED = Integer(ENV.fetch("DATA_MATRIX_SEED", "20261113"))
+ DMTX_MISSING = "dmtxread is not installed: apt-get install dmtx-utils or brew install dmtx-utils"
+
+ def test_every_size_reads_back_as_written
+ skip DMTX_MISSING unless dmtx?
+
+ random = Random.new(SEED)
+ texts = ["A", "0123456789", "https://example.com/parcel/0001", "Café", *[10, 40, 100, 250, 500, 900, 1500].map { |length| Array.new(length) { (32 + random.rand(95)).chr }.join }]
+ sizes = texts.map do |text|
+ symbol = nil
+ pdf = Prawn::Document.new { symbol = data_matrix text, module_size: 2 }.render
+
+ assert_equal text.b, read_back(pdf), "#{text.bytesize} bytes in #{symbol.size}x#{symbol.size}"
+ symbol.size
+ end
+
+ assert_equal [10, 144], sizes.minmax
+ assert_operator sizes.uniq.size, :>=, 9, "sizes with one and several data regions and interleaved blocks"
+ end
+
+ def test_the_smallest_square_size_that_holds_the_data_is_chosen
+ assert_equal 10, Gamba::Barcodes::DataMatrix.new("123456").size, "three digit pairs fill a 10x10"
+ assert_equal 12, Gamba::Barcodes::DataMatrix.new("1234567").size
+ assert_equal 144, Gamba::Barcodes::DataMatrix.new("1" * 3116).size
+ assert_equal "3117 bytes take 1559 codewords: a DataMatrix holds 1558 at most", assert_raises(ArgumentError) { Gamba::Barcodes::DataMatrix.new("1" * 3117) }.message
+ end
+
+ def test_a_symbol_is_one_fill_its_size_counting_a_one_module_quiet_zone
+ symbol = nil
+ document = Prawn::Document.new { symbol = data_matrix "alice", at: [0, 700], size: 56 }
+ content = uncompressed(document.render)[/stream\n(.*?)endstream/m, 1]
+ rectangles = content.scan(/^([\d.]+) ([\d.]+) ([\d.]+) 4.0 re$/)
+
+ assert_equal 12, symbol.size
+ assert_equal 1, content.scan(/^f$/).size
+ assert_equal 36 + 4, rectangles.map { _1[0].to_f }.min
+ assert_equal 36 + 700 - 4 - (12 * 4), rectangles.map { _1[1].to_f }.min, "the solid bottom edge"
+ end
+
+ def test_the_native_api_draws_a_data_matrix_as_a_box
+ skip DMTX_MISSING unless dmtx?
+
+ document = Gamba.document do |yielded|
+ yielded.row do |row|
+ row.text "Parcel"
+ row.data_matrix "alice@example.com", size: 80, align: :center
+ end
+ end
+
+ assert_equal "alice@example.com", read_back(document.to_s)
+ assert_raises(ArgumentError) { Gamba.document.data_matrix("alice", level: :h) }
+ end
+
+ private
+
+ def dmtx?
+ system("dmtxread", "--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("dmtxread", "--newline", "--stop-after=1", File.join(directory, "page.png"), err: File::NULL, binmode: true)
+ output.chomp
+ end
+ end
+
+ def uncompressed(pdf)
+ Tempfile.create(["data_matrix", ".pdf"]) do |file|
+ file.binmode
+ file.write(pdf)
+ file.flush
+ Open3.capture2("qpdf", "--qdf", "--object-streams=disable", file.path, "-").first.b
+ end
+ end
+end