From 8c2025768821014fcb3c03fa15233633234b3f1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 09:04:04 +0000 Subject: [PATCH] Draw QR codes as vectors, encoded in pure Ruby qr_code encodes text in the smallest version that holds it at the level asked for, in numeric, alphanumeric or byte mode with UTF-8 announced by an ECI, and draws it as one path of rectangles and one fill. size counts the quiet zone, module_size sets one module; without at, the code is placed as an image is. Gamba's own API takes qr_code as a box. CI installs zbar, which reads every code the tests draw back from the rendered page. Assisted-by: Claude [Claude Code] --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- README.md | 10 +- docs/guide.html | 10 + lib/gamba.rb | 3 +- lib/gamba/barcodes.rb | 68 +++++++ lib/gamba/barcodes/qr_code.rb | 333 ++++++++++++++++++++++++++++++++++ lib/gamba/document.rb | 1 + lib/gamba/native/layout.rb | 44 +++++ test/contract/gamba.yml | 2 + test/qr_code_test.rb | 114 ++++++++++++ 11 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 lib/gamba/barcodes.rb create mode 100644 lib/gamba/barcodes/qr_code.rb create mode 100644 test/qr_code_test.rb 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 @@

Guide

  • SVG
  • Fonts
  • Images
  • +
  • QR codes
  • PDF/A and Factur-X
  • Output
  • Servers that fork
  • @@ -233,6 +234,15 @@

    Pages of other PDFs

    +
    +

    QR codes

    +
    +

    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.

    +
    +
    +

    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