Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ <h1 class="page-title">Guide</h1>
<li><a href="#svg">SVG</a></li>
<li><a href="#fonts">Fonts</a></li>
<li><a href="#images">Images</a></li>
<li><a href="#barcodes">QR codes and barcodes</a></li>
<li><a href="#barcodes">QR codes, DataMatrix and barcodes</a></li>
<li><a href="#pdf-a">PDF/A and Factur-X</a></li>
<li><a href="#output">Output</a></li>
<li><a href="#servers">Servers that fork</a></li>
Expand Down Expand Up @@ -235,11 +235,12 @@ <h3 id="pdf-pages">Pages of other PDFs</h3>
</section>

<section id="barcodes" aria-labelledby="barcodes-title">
<h2 id="barcodes-title">QR codes and barcodes</h2>
<h2 id="barcodes-title">QR codes, DataMatrix and barcodes</h2>
<div class="prose">
<p>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.</p>
<pre><code class="language-ruby"><span class="n">qr_code</span> <span class="s2">"https://example.com/pay/INV-2026-0001"</span><span class="p">,</span> <span class="ss">at: </span><span class="p">[</span><span class="mi">400</span><span class="p">,</span> <span class="mi">700</span><span class="p">],</span> <span class="ss">size: </span><span class="mi">80</span><span class="p">,</span> <span class="ss">level: :m</span></code></pre>
<p><code>size</code> is the width of the whole symbol, its quiet zone of four modules included; <code>module_size</code> gives the width of one module instead (2 points unless told). <code>level</code> is <code>:l</code>, <code>:m</code> (the default), <code>:q</code> or <code>:h</code>, and the smallest version that holds the text at that level is chosen; text too long for any raises <code>ArgumentError</code>. Without <code>at</code>, the code is placed as an image is, with <code>position</code> and <code>vposition</code>. <code>quiet_zone: false</code> leaves the margin to the layout, and <code>color</code> sets the colour of the modules. In Gamba’s own API, <code>qr_code</code> is a box, with <code>size</code> or <code>module_size</code>, <code>level</code>, <code>quiet_zone</code>, <code>color</code> and <code>align</code>.</p>
<p>A DataMatrix, which carriers ask for on parcel labels, takes the same options but <code>level</code>: <code>data_matrix "alice@example.com", size: 40</code> 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.</p>
<p>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.</p>
<pre><code class="language-ruby"><span class="n">barcode</span> <span class="s2">"0123456789012"</span><span class="p">,</span> <span class="ss">type: :ean13</span><span class="p">,</span> <span class="ss">width: </span><span class="mi">120</span><span class="p">,</span> <span class="ss">height: </span><span class="mi">40</span><span class="p">,</span> <span class="ss">text: </span><span class="kp">true</span></code></pre>
<p><code>width</code> counts the quiet zones, or <code>module_size</code> sets the narrowest bar (2 points unless told); <code>height</code> is the bars’, 50 modules unless told. <code>text: true</code> writes the encoded text under the bars, check digit included, at <code>text_size</code> (8 modules unless told). <code>at</code>, <code>position</code>, <code>vposition</code>, <code>quiet_zone</code> and <code>color</code> work as for QR codes, and in Gamba’s own API <code>barcode</code> is a box too.</p>
Expand Down
21 changes: 15 additions & 6 deletions lib/gamba/barcodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
204 changes: 204 additions & 0 deletions lib/gamba/barcodes/data_matrix.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/gamba/barcodes/qr_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading