From b04726d39956db3eeff36109be9e00b37ad1afec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 09:27:11 +0000 Subject: [PATCH] Draw DataMatrix codes as vectors, encoded in pure Ruby data_matrix encodes the data in ASCII encodation, digits two to a codeword, in the smallest of the 24 square ECC 200 sizes that holds it, with Reed-Solomon blocks interleaved as the larger sizes need, and draws it as one path and one fill with a one-module quiet zone. QR codes and DataMatrix share their drawing and, in Gamba's own API, one box. CI installs dmtx-utils, whose dmtxread reads every symbol the tests draw back from the rendered page. Assisted-by: Claude [Claude Code] --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- README.md | 9 +- docs/guide.html | 5 +- lib/gamba/barcodes.rb | 21 ++- lib/gamba/barcodes/data_matrix.rb | 204 ++++++++++++++++++++++++++++++ lib/gamba/barcodes/qr_code.rb | 6 + lib/gamba/native/layout.rb | 29 +++-- test/contract/gamba.yml | 6 +- test/data_matrix_test.rb | 85 +++++++++++++ 10 files changed, 341 insertions(+), 28 deletions(-) create mode 100644 lib/gamba/barcodes/data_matrix.rb create mode 100644 test/data_matrix_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f7ce06..45d83f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: for attempt in 1 2 3; do if sudo timeout 90 apt-get update; then sudo timeout 90 apt-get install --yes \ - -o Dir::Cache::Archives="$HOME/.cache/apt-archives" mupdf-tools poppler-utils qpdf zbar-tools && break + -o Dir::Cache::Archives="$HOME/.cache/apt-archives" mupdf-tools poppler-utils qpdf zbar-tools dmtx-utils && break else sudo dpkg --install ~/.cache/apt-archives/*.deb && break fi diff --git a/AGENTS.md b/AGENTS.md index ec959e2..d9bada1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ files. `lib/prawn*.rb` and `lib/pdf` answer Prawn's requires; the code lives in ```sh bundle install && BUNDLE_GEMFILE=oracle/Gemfile bundle install # Prawn runs from its own bundle bundle exec rake corpus:fetch # Prawn's manual and data, into corpus/cache -bundle exec rake test # needs: brew install mupdf-tools poppler qpdf zbar +bundle exec rake test # needs: brew install mupdf-tools poppler qpdf zbar dmtx-utils ORACLE_CANDIDATE=prawn FUZZ_CASES=0 bundle exec rake test # Prawn against itself: proves the oracle FUZZ_SEED=42 FUZZ_CASES=500 bundle exec ruby -Itest test/fuzz_test.rb INPUT_FUZZ_SEED=42 INPUT_FUZZ_CASES=400 bundle exec ruby -Itest test/input_fuzz_test.rb diff --git a/README.md b/README.md index 51716ae..9434786 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,13 @@ Prawn::Document.new(background_pdf: "letterhead.pdf") do end ``` -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: +QR codes, DataMatrix 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 and libdmtx 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 +data_matrix "alice@example.com", size: 40 ``` Loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they share it in @@ -222,8 +223,8 @@ its proof. ## Development Prawn runs from its own bundle, `oracle/Gemfile`, so the oracle can compare the two. The tests need `mutool`, -`pdftoppm`, `qpdf` and `zbarimg` (`brew install mupdf-tools poppler qpdf zbar`), and Prawn's manual and sample data, -which `rake corpus:fetch` downloads into `corpus/cache`. Without that cache, the comparison tests are skipped. +`pdftoppm`, `qpdf`, `zbarimg` and `dmtxread` (`brew install mupdf-tools poppler qpdf zbar dmtx-utils`), and Prawn's +manual and sample data, which `rake corpus:fetch` downloads into `corpus/cache`. Without that cache, the comparison tests are skipped. ```sh bundle install && BUNDLE_GEMFILE=oracle/Gemfile bundle install diff --git a/docs/guide.html b/docs/guide.html index 6469467..b095be7 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -42,7 +42,7 @@

Guide

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

    Pages of other PDFs

    -

    QR codes and barcodes

    +

    QR codes, DataMatrix 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.

    +

    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