diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 493112e..4f7ce06 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 && break
+ -o Dir::Cache::Archives="$HOME/.cache/apt-archives" mupdf-tools poppler-utils qpdf zbar-tools && break
else
sudo dpkg --install ~/.cache/apt-archives/*.deb && break
fi
diff --git a/AGENTS.md b/AGENTS.md
index 163330b..ec959e2 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
+bundle exec rake test # needs: brew install mupdf-tools poppler qpdf zbar
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 d343576..c5c1f98 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +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:
+
+```ruby
+qr_code "https://example.com/pay/INV-2026-0001", at: [400, 700], size: 80, level: :m
+```
+
Loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they share it in
memory:
@@ -214,8 +220,8 @@ its proof.
## Development
Prawn runs from its own bundle, `oracle/Gemfile`, so the oracle can compare the two. The tests need `mutool`,
-`pdftoppm` and `qpdf` (`brew install mupdf-tools poppler qpdf`), 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` 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.
```sh
bundle install && BUNDLE_GEMFILE=oracle/Gemfile bundle install
diff --git a/docs/guide.html b/docs/guide.html
index 7b3770b..a542134 100644
--- a/docs/guide.html
+++ b/docs/guide.html
@@ -42,6 +42,7 @@
PDF/A and Factur-X
diff --git a/lib/gamba.rb b/lib/gamba.rb
index 53392f0..111efda 100644
--- a/lib/gamba.rb
+++ b/lib/gamba.rb
@@ -55,6 +55,7 @@ def verify_options(accepted, actual)
require_relative "gamba/images"
require_relative "gamba/images/png_decoder"
require_relative "gamba/images/image"
+require_relative "gamba/barcodes"
require_relative "gamba/repeater"
require_relative "gamba/grid"
require_relative "gamba/outline"
@@ -121,7 +122,7 @@ def self.combine(sources, page_size: nil, orientation: nil)
# Loads what Gamba otherwise loads on first use, SVG support included: call it before a server forks its
# workers, so they share that memory instead of each loading its own copy.
def self.eager_load!
- [TrueType::File, TrueType::Subset, Prawn::Fonts::TTF, Combine, ImportedPage, PDF::Core::Reader, Document]
+ [TrueType::File, TrueType::Subset, Prawn::Fonts::TTF, Combine, ImportedPage, Barcodes::QRCode, PDF::Core::Reader, Document]
require "prawn-svg"
end
end
diff --git a/lib/gamba/barcodes.rb b/lib/gamba/barcodes.rb
new file mode 100644
index 0000000..53bd3ba
--- /dev/null
+++ b/lib/gamba/barcodes.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+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__)
+ end
+end
+
+module Prawn
+ # Codes drawn as vector rectangles, one path and one fill per symbol: sharp at any size, and a sheet of labels
+ # 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
+ DEFAULT_MODULE_SIZE = 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.
+ 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)
+ symbol
+ 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
+ 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
+ if options[:at]
+ left, top = map_to_absolute(options[:at])
+ else
+ left, top = image_position(width, width, options.dup)
+ move_text_position(width)
+ end
+ save_graphics_state do
+ fill_color(options.fetch(:color, "000000"))
+ add_content("#{module_path(symbol, left + (quiet_zone * module_size), top - (quiet_zone * module_size), module_size)}f")
+ end
+ end
+
+ private
+
+ # Dark modules side by side on a row are one rectangle of the path.
+ def module_path(symbol, left, top, module_size)
+ path = +""
+ symbol.size.times do |y|
+ x = 0
+ while x < symbol.size
+ unless symbol.dark?(x, y)
+ x += 1
+ next
+ end
+
+ run = x
+ run += 1 while run < symbol.size && symbol.dark?(run, y)
+ path << PDF::Core.real_params([left + (x * module_size), top - ((y + 1) * module_size), (run - x) * module_size, module_size]) << " re\n"
+ x = run
+ end
+ end
+ path
+ end
+ end
+end
diff --git a/lib/gamba/barcodes/qr_code.rb b/lib/gamba/barcodes/qr_code.rb
new file mode 100644
index 0000000..02a4950
--- /dev/null
+++ b/lib/gamba/barcodes/qr_code.rb
@@ -0,0 +1,333 @@
+# frozen_string_literal: true
+
+module Gamba
+ module Barcodes
+ # A QR code (ISO/IEC 18004:2015) as a grid of modules: the smallest version that holds the text at the level of
+ # error correction asked for, in numeric, alphanumeric or byte mode, UTF-8 announced by an ECI when the text
+ # is not ASCII, and the mask the standard's penalty rules prefer.
+ class QRCode
+ LEVELS = %i[l m q h].freeze
+ # 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).
+ ECC_CODEWORDS_PER_BLOCK = {
+ l: [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+ m: [10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
+ q: [13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+ h: [17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
+ }.freeze
+ BLOCKS = {
+ l: [1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
+ m: [1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
+ q: [1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
+ h: [1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81]
+ }.freeze
+ ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
+ ALPHANUMERIC_VALUES = ALPHANUMERIC.each_char.with_index.to_h.freeze
+ # Mode indicator and character count bits for versions 1-9, 10-26 and 27-40.
+ MODES = {numeric: [0x1, [10, 12, 14]], alphanumeric: [0x2, [9, 11, 13]], byte: [0x4, [8, 16, 16]]}.freeze
+ VERSION_RANGES = [1..9, 10..26, 27..40].freeze
+ ECI_MODE = 0x7
+ UTF8_ECI = 26
+ PAD_BYTES = [0xEC, 0x11].freeze
+ MASKS = [
+ ->(x, y) { (x + y).even? },
+ ->(_x, y) { y.even? },
+ ->(x, _y) { (x % 3).zero? },
+ ->(x, y) { ((x + y) % 3).zero? },
+ ->(x, y) { ((x / 3) + (y / 2)).even? },
+ ->(x, y) { ((x * y) % 2) + ((x * y) % 3) == 0 },
+ ->(x, y) { (((x * y) % 2) + ((x * y) % 3)).even? },
+ ->(x, y) { (((x + y) % 2) + ((x * y) % 3)).even? }
+ ].freeze
+ # Penalty weights N1 to N4 (§7.8.3.1), and the 1:1:3:1:1 pattern of a finder with four light modules beside it.
+ PENALTY_RUN = 3
+ PENALTY_BLOCK = 3
+ PENALTY_FINDER_LIKE = 40
+ PENALTY_BALANCE = 10
+ EXPONENTS = Array.new(510).tap do |powers|
+ value = 1
+ 255.times do |exponent|
+ powers[exponent] = powers[exponent + 255] = value
+ value = (value << 1) ^ ((value >> 7) * 0x11D)
+ end
+ end.freeze
+ LOGARITHMS = Array.new(256).tap { |logarithms| 255.times { logarithms[EXPONENTS[_1]] = _1 } }.freeze
+ # Generator polynomials by degree, each computed when first used: two threads computing one write the same.
+ DIVISORS = {} # rubocop:disable Style/MutableConstant -- a cache
+ LONG_RUN = /0{5,}|1{5,}/
+ FINDER_LIKE = /(?=10111010000|00001011101)/
+
+ attr_reader :version, :level, :mask, :size
+
+ def initialize(text, level: :m)
+ raise ArgumentError, "level is #{level.inspect}: accepted levels are #{LEVELS.map(&:inspect).join(", ")}" unless LEVELS.include?(level)
+
+ @level = level
+ bytes = text.to_s.encode(Encoding::UTF_8).b
+ @version, bits = encode(bytes)
+ @size = (@version * 4) + 17
+ @modules = Array.new(@size) { Array.new(@size, false) }
+ @function = Array.new(@size) { Array.new(@size, false) }
+ draw_function_patterns
+ place(interleaved(codewords(bits)))
+ @mask = (0...MASKS.size).min_by { |mask| penalty_with(mask) }
+ apply_mask(@mask)
+ draw_format_bits(@mask)
+ end
+
+ def dark?(x, y)
+ @modules[y][x]
+ end
+
+ private
+
+ # The bits of the one segment that holds the text, in the smallest version where they fit.
+ def encode(bytes)
+ mode = if bytes.match?(/\A\d*\z/n) then :numeric
+ elsif bytes.each_char.all? { ALPHANUMERIC_VALUES.key?(_1) } then :alphanumeric
+ else :byte
+ end
+ (1..40).each do |version|
+ bits = segment_bits(bytes, mode, version)
+ return [version, bits] if bits && bits.size <= data_codewords(version) * 8
+ end
+ raise ArgumentError, "#{bytes.bytesize} bytes do not fit a QR code at level #{@level.inspect}: lower the level or shorten the text"
+ end
+
+ def segment_bits(bytes, mode, version)
+ indicator, count_widths = MODES.fetch(mode)
+ count_width = count_widths[VERSION_RANGES.index { _1.cover?(version) }]
+ count = (mode == :byte) ? bytes.bytesize : bytes.size
+ return nil if count >= (1 << count_width)
+
+ bits = []
+ if mode == :byte && !bytes.ascii_only?
+ append(bits, ECI_MODE, 4)
+ append(bits, UTF8_ECI, 8)
+ end
+ append(bits, indicator, 4)
+ append(bits, count, count_width)
+ case mode
+ when :numeric then bytes.scan(/\d{1,3}/n) { |digits| append(bits, digits.to_i, (digits.size * 3) + 1) }
+ when :alphanumeric
+ bytes.scan(/.{1,2}/mn) do |pair|
+ values = pair.each_char.map { ALPHANUMERIC_VALUES.fetch(_1) }
+ (values.size == 2) ? append(bits, (values[0] * 45) + values[1], 11) : append(bits, values[0], 6)
+ end
+ else bytes.each_byte { append(bits, _1, 8) }
+ end
+ bits
+ end
+
+ def append(bits, value, width)
+ (width - 1).downto(0) { bits << value[_1] }
+ bits
+ end
+
+ # Terminator, padding to a byte, then pad codewords up to the version's data capacity.
+ def codewords(bits)
+ capacity = data_codewords(@version) * 8
+ bits.concat([0] * [4, capacity - bits.size].min)
+ bits.concat([0] * (-bits.size % 8))
+ data = bits.each_slice(8).map { |byte| byte.inject(0) { (_1 << 1) | _2 } }
+ data << PAD_BYTES[(data.size - (capacity / 8)) % 2] while data.size < capacity / 8
+ data
+ end
+
+ def raw_modules(version)
+ result = (((16 * version) + 128) * version) + 64
+ if version >= 2
+ alignments = (version / 7) + 2
+ result -= (((25 * alignments) - 10) * alignments) - 55
+ result -= 36 if version >= 7
+ end
+ result
+ end
+
+ def data_codewords(version)
+ (raw_modules(version) / 8) - (ECC_CODEWORDS_PER_BLOCK.fetch(@level)[version - 1] * BLOCKS.fetch(@level)[version - 1])
+ end
+
+ # Data split into blocks, each followed by its Reed-Solomon codewords, then read across the blocks.
+ def interleaved(data)
+ block_count = BLOCKS.fetch(@level)[@version - 1]
+ ecc_length = ECC_CODEWORDS_PER_BLOCK.fetch(@level)[@version - 1]
+ raw = raw_modules(@version) / 8
+ short_blocks = block_count - (raw % block_count)
+ short_length = raw / block_count
+ divisor = reed_solomon_divisor(ecc_length)
+ offset = 0
+ blocks = Array.new(block_count) do |index|
+ length = short_length - ecc_length + ((index < short_blocks) ? 0 : 1)
+ block = data[offset, length]
+ offset += length
+ remainder = reed_solomon_remainder(block, divisor)
+ block << nil if index < short_blocks
+ block + remainder
+ end
+ (0...blocks.first.size).flat_map { |position| blocks.filter_map { _1[position] } }
+ end
+
+ def reed_solomon_divisor(degree)
+ DIVISORS[degree] ||= divisor(degree)
+ end
+
+ def divisor(degree)
+ result = Array.new(degree, 0)
+ result[-1] = 1
+ root = 1
+ degree.times do
+ degree.times do |index|
+ result[index] = multiply(result[index], root)
+ result[index] ^= result[index + 1] if index + 1 < degree
+ end
+ root = multiply(root, 0x02)
+ end
+ result
+ end
+
+ def reed_solomon_remainder(data, divisor)
+ result = Array.new(divisor.size, 0)
+ data.each do |byte|
+ factor = byte ^ result.shift
+ result << 0
+ divisor.each_with_index { |coefficient, index| result[index] ^= multiply(coefficient, factor) }
+ end
+ result
+ end
+
+ # In GF(2^8) modulo x^8 + x^4 + x^3 + x^2 + 1, through tables of powers of 2 and their logarithms.
+ def multiply(x, y)
+ (x.zero? || y.zero?) ? 0 : EXPONENTS[LOGARITHMS[x] + LOGARITHMS[y]]
+ end
+
+ def draw_function_patterns
+ @size.times do |index|
+ set_function(6, index, index.even?)
+ set_function(index, 6, index.even?)
+ end
+ [[3, 3], [@size - 4, 3], [3, @size - 4]].each { |x, y| draw_finder(x, y) }
+ positions = alignment_positions
+ positions.each_with_index do |y, row|
+ positions.each_with_index do |x, column|
+ next if (row.zero? && column.zero?) || (row.zero? && column == positions.size - 1) || (row == positions.size - 1 && column.zero?)
+
+ (-2..2).each { |dy| (-2..2).each { |dx| set_function(x + dx, y + dy, [dx.abs, dy.abs].max != 1) } }
+ end
+ end
+ draw_format_bits(0)
+ draw_version
+ end
+
+ def draw_finder(x, y)
+ (-4..4).each do |dy|
+ (-4..4).each do |dx|
+ distance = [dx.abs, dy.abs].max
+ set_function(x + dx, y + dy, distance != 2 && distance != 4) if (x + dx).between?(0, @size - 1) && (y + dy).between?(0, @size - 1)
+ end
+ end
+ end
+
+ def alignment_positions
+ return [] if @version == 1
+
+ count = (@version / 7) + 2
+ step = (((@version * 8) + (count * 3) + 5) / ((count * 4) - 4)) * 2
+ [6] + Array.new(count - 1) { |index| @size - 7 - (index * step) }.reverse
+ end
+
+ # Format information, in both its copies, and the dark module beside the lower left finder.
+ def draw_format_bits(mask)
+ data = (FORMAT_BITS.fetch(@level) << 3) | mask
+ remainder = data
+ 10.times { remainder = (remainder << 1) ^ ((remainder >> 9) * 0x537) }
+ bits = ((data << 10) | remainder) ^ 0x5412
+ (0..5).each { set_function(8, _1, bits[_1] == 1) }
+ set_function(8, 7, bits[6] == 1)
+ set_function(8, 8, bits[7] == 1)
+ set_function(7, 8, bits[8] == 1)
+ (9..14).each { set_function(14 - _1, 8, bits[_1] == 1) }
+ (0..7).each { set_function(@size - 1 - _1, 8, bits[_1] == 1) }
+ (8..14).each { set_function(8, @size - 15 + _1, bits[_1] == 1) }
+ set_function(8, @size - 8, true)
+ end
+
+ def draw_version
+ return if @version < 7
+
+ remainder = @version
+ 12.times { remainder = (remainder << 1) ^ ((remainder >> 11) * 0x1F25) }
+ bits = (@version << 12) | remainder
+ 18.times do |index|
+ dark = bits[index] == 1
+ a = @size - 11 + (index % 3)
+ b = index / 3
+ set_function(a, b, dark)
+ set_function(b, a, dark)
+ end
+ end
+
+ def set_function(x, y, dark)
+ @modules[y][x] = dark
+ @function[y][x] = true
+ end
+
+ # Codewords in two-module columns, from the lower right corner, up then down, skipping the vertical timing line.
+ def place(codewords)
+ bit_count = codewords.size * 8
+ index = 0
+ right = @size - 1
+ while right >= 1
+ right = 5 if right == 6
+ upward = ((right + 1) & 2).zero?
+ @size.times do |vertical|
+ y = upward ? @size - 1 - vertical : vertical
+ [right, right - 1].each do |x|
+ next if @function[y][x] || index >= bit_count
+
+ @modules[y][x] = codewords[index >> 3][7 - (index & 7)] == 1
+ index += 1
+ end
+ end
+ right -= 2
+ end
+ end
+
+ def apply_mask(mask)
+ rule = MASKS.fetch(mask)
+ @size.times do |y|
+ row = @modules[y]
+ function = @function[y]
+ @size.times { |x| row[x] = !row[x] if !function[x] && rule.call(x, y) }
+ end
+ end
+
+ def penalty_with(mask)
+ apply_mask(mask)
+ draw_format_bits(mask)
+ penalty
+ ensure
+ apply_mask(mask)
+ end
+
+ # Rows and columns as strings of 0 and 1, which regular expressions scan for runs and finder-like patterns.
+ def penalty
+ rows = @modules.map { |row| row.map { _1 ? "1" : "0" }.join }
+ columns = @modules.transpose.map! { |column| column.map { _1 ? "1" : "0" }.join }
+ score = (rows + columns).sum do |line|
+ line.scan(LONG_RUN).sum { PENALTY_RUN + _1.size - 5 } + (line.scan(FINDER_LIKE).size * PENALTY_FINDER_LIKE)
+ end
+ # 2x2 blocks of one colour: bits equal to their right neighbour in both rows, and between the rows.
+ within = (1 << (@size - 1)) - 1
+ rows.map { _1.to_i(2) }.each_cons(2) do |upper, lower|
+ same = ~(upper ^ (upper >> 1)) & ~(lower ^ (lower >> 1)) & ~(upper ^ lower) & within
+ score += same.to_s(2).count("1") * PENALTY_BLOCK
+ end
+ dark = rows.sum { _1.count("1") }
+ total = @size * @size
+ score + (((((dark * 20) - (total * 10)).abs + total - 1) / total) - 1) * PENALTY_BALANCE
+ end
+ end
+ end
+end
diff --git a/lib/gamba/document.rb b/lib/gamba/document.rb
index de4be65..efa8401 100644
--- a/lib/gamba/document.rb
+++ b/lib/gamba/document.rb
@@ -10,6 +10,7 @@ class Document
include PDF::Core::Destinations
include Security
include Images
+ include Barcodes
include Gamba::PdfA
include Table::Interface
include Stamp
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index 5ee85a3..50e9b83 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -289,6 +289,45 @@ 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
+
+ def initialize(text, size: nil, module_size: Prawn::Barcodes::DEFAULT_MODULE_SIZE, level: :m, 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:)
+ @size = size
+ @module_size = module_size
+ @quiet_zone = quiet_zone
+ @color = color
+ @align = align
+ end
+
+ 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)
+ end
+
+ private
+
+ def measure(_prawn, width)
+ side(width)
+ end
+
+ def side(available_width)
+ modules = @symbol.size + (@quiet_zone ? 2 * Prawn::Barcodes::QR_QUIET_ZONE_MODULES : 0)
+ [@size || width || (modules * @module_size), available_width].min
+ end
+
+ def description
+ "a QR code"
+ 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
@@ -302,6 +341,11 @@ def image(source, **options)
add(Image.new(source, **options))
end
+ def qr_code(text, **options)
+ Styles.check_options(options, QRCode::OPTIONS)
+ add(QRCode.new(text, **options))
+ end
+
# A page of another PDF (a path, an IO or its bytes), from 1, as a box sized like an image.
def pdf_page(source, page: 1, **options)
Styles.check_options(options, Image::OPTIONS)
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index 2315661..76c8969 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -16,6 +16,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]
"#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]
@@ -28,6 +29,7 @@ Gamba::Layout::Container:
"#grid": [keyreq columns, keyrest options]
"#image": [req source, keyrest options]
"#pdf_page": [req source, key page, keyrest options]
+ "#qr_code": [req text, keyrest options]
"#row": [keyrest options]
"#stack": [keyrest options]
"#table": [req rows, keyrest options, block styling]
diff --git a/test/qr_code_test.rb b/test/qr_code_test.rb
new file mode 100644
index 0000000..fcb6ecb
--- /dev/null
+++ b/test/qr_code_test.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "open3"
+require "prawn"
+
+# QR codes drawn as vector rectangles, read back by zbar from the rendered page: what is encoded must be what a
+# phone reads.
+class QRCodeTest < Minitest::Test
+ SEED = Integer(ENV.fetch("QR_CODE_SEED", "20261113"))
+ ZBAR_MISSING = "zbarimg is not installed: apt-get install zbar-tools or brew install zbar"
+
+ def test_every_mode_and_level_reads_back_as_written
+ skip ZBAR_MISSING unless zbar?
+
+ random = Random.new(SEED)
+ texts = ["https://example.com/pay/INV-2026-0001", "0123456789" * 3, "HELLO WORLD $%*+-./:", "Café €, 日本 — alice@example.com", ""]
+ texts += [1, 20, 120, 400].map { |length| Array.new(length) { (32 + random.rand(95)).chr }.join }
+ Gamba::Barcodes::QRCode::LEVELS.each do |level|
+ texts.each do |text|
+ assert_equal text, read_back(Prawn::Document.new { qr_code text, level:, module_size: 2 }.render), "#{text.inspect} at level #{level}"
+ end
+ end
+ end
+
+ def test_the_smallest_version_that_holds_the_text_is_chosen_up_to_the_standard_capacities
+ capacities = {["1" * 41, :l] => 1, ["1" * 42, :l] => 2, ["a" * 14, :m] => 1, ["a" * 15, :m] => 2, ["1" * 7089, :l] => 40, ["A" * 4296, :l] => 40, ["a" * 2953, :l] => 40, ["a" * 1273, :h] => 40}
+
+ capacities.each { |(text, level), version| assert_equal version, Gamba::Barcodes::QRCode.new(text, level:).version, "#{text.size} characters at level #{level}" }
+ assert_match(/\A1274 bytes do not fit a QR code at level :h: /, assert_raises(ArgumentError) { Gamba::Barcodes::QRCode.new("a" * 1274, level: :h) }.message)
+ end
+
+ def test_large_versions_read_back
+ skip ZBAR_MISSING unless zbar?
+
+ text = Array.new(1500) { |index| (65 + (index % 26)).chr }.join.downcase
+ pdf = Prawn::Document.new { qr_code text, level: :l, size: 500 }.render
+
+ assert_equal 28, Gamba::Barcodes::QRCode.new(text, level: :l).version
+ assert_equal text, read_back(pdf)
+ end
+
+ def test_a_symbol_is_one_path_and_one_fill_its_size_counting_the_quiet_zone
+ symbol = nil
+ document = Prawn::Document.new { symbol = qr_code "alice", at: [100, 700], size: 116 }
+ content = uncompressed(document.render)[/stream\n(.*?)endstream/m, 1]
+ rectangles = content.scan(/^([\d.]+) ([\d.]+) ([\d.]+) 4.0 re$/)
+ lefts = rectangles.map { _1[0].to_f }
+ tops = rectangles.map { _1[1].to_f + 4 }
+
+ assert_equal 21, symbol.size
+ assert_equal 1, content.scan(/^f$/).size
+ assert_equal [36 + 100 + 16, 36 + 700 - 16], [lefts.min, tops.max]
+ assert_equal 36 + 100 + 16 + 84, rectangles.map { _1[0].to_f + _1[2].to_f }.max
+ end
+
+ def test_without_at_a_code_flows_as_an_image_does
+ document = Prawn::Document.new do
+ text "Pay"
+ qr_code "alice", module_size: 3, position: :right, quiet_zone: false
+ end
+
+ assert_in_delta 720 - document.height_of("Pay") - 63, document.cursor, 0.001
+ assert_includes uncompressed(document.render), "549.0 "
+ end
+
+ def test_wrong_options_raise
+ assert_raises(ArgumentError) { Prawn::Document.new.qr_code("alice", level: :x) }
+ assert_raises(ArgumentError) { Gamba.document.qr_code("alice", sise: 10) }
+ assert_raises(ArgumentError) { Gamba.document.qr_code("alice", align: :middle) }
+ end
+
+ def test_the_native_api_draws_a_code_as_a_box
+ skip ZBAR_MISSING unless zbar?
+
+ cursor = nil
+ document = Gamba.document do |yielded|
+ yielded.row do |row|
+ row.text "Scan to pay"
+ row.qr_code "https://example.com/pay/INV-2026-0001", size: 100, align: :right
+ end
+ cursor = yielded.prawn.cursor
+ yielded.text "after"
+ end
+
+ assert_equal "https://example.com/pay/INV-2026-0001", read_back(document.to_s)
+ assert_in_delta 720 - 100, cursor, 0.001
+ end
+
+ private
+
+ def zbar?
+ system("zbarimg", "--version", out: File::NULL, err: File::NULL)
+ end
+
+ # What zbar reads on the first page drawn at 150 dpi.
+ 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", "--raw", "-Sbinary", File.join(directory, "page.png"), err: File::NULL)
+ output.chomp.force_encoding(Encoding::UTF_8)
+ end
+ end
+
+ def uncompressed(pdf)
+ Tempfile.create(["qr", ".pdf"]) do |file|
+ file.binmode
+ file.write(pdf)
+ file.flush
+ Open3.capture2("qpdf", "--qdf", "--object-streams=disable", file.path, "-").first.b
+ end
+ end
+end