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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +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:
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:

```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
```

Loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they share it in
Expand Down
7 changes: 5 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</a></li>
<li><a href="#barcodes">QR codes 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,14 @@ <h3 id="pdf-pages">Pages of other PDFs</h3>
</section>

<section id="barcodes" aria-labelledby="barcodes-title">
<h2 id="barcodes-title">QR codes</h2>
<h2 id="barcodes-title">QR codes 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>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>
</div>
</section>

Expand Down
59 changes: 59 additions & 0 deletions lib/gamba/barcodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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__)
autoload :Linear, ::File.expand_path("barcodes/linear", __dir__)
end
end

Expand All @@ -15,6 +16,11 @@ module Barcodes
# 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
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.
DEFAULT_BAR_HEIGHT_MODULES = 50
TEXT_SIZE_MODULES = 8
TEXT_GAP_MODULES = 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.
Expand All @@ -25,6 +31,49 @@ def qr_code(text, options = {})
symbol
end

# A linear barcode of the data, type :code128, :ean13, :ean8, :code39 or :itf14, from the top left corner at
# `at`, or placed as an image is. width counts the quiet zones, or module_size sets the narrowest bar; height is
# the bars'. text: true writes the encoded text under them, check digit included. Returns the symbol.
def barcode(data, options = {})
Prawn.verify_options(BARCODE_OPTIONS, options)
raise ArgumentError, "barcode needs a type: :code128, :ean13, :ean8, :code39 or :itf14" unless options[:type]

symbol = Gamba::Barcodes::Linear.new(data, type: options[:type])
draw_barcode(symbol, options)
symbol
end

# A symbol already encoded, with the options of barcode but type.
def draw_barcode(symbol, options)
quiet_left, module_size, bar_height, text_size, width, height = Barcodes.linear_dimensions(symbol, options)
if options[:at]
left, top = map_to_absolute(options[:at])
else
left, top = image_position(width, height, options.dup)
move_text_position(height)
end
bars_left = left + (quiet_left * module_size)
save_graphics_state do
fill_color(options.fetch(:color, "000000"))
add_content("#{bar_path(symbol, bars_left, top - bar_height, module_size, bar_height)}f")
if text_size
text_box symbol.text, at: [bars_left - bounds.absolute_left, top - bar_height - (TEXT_GAP_MODULES * module_size) - bounds.absolute_bottom],
width: symbol.length * module_size, height: text_size * 1.5, size: text_size, align: :center, overflow: :shrink_to_fit
end
end
end

# The left quiet zone in modules, the module, bar and text sizes, and the whole symbol's width and height.
def self.linear_dimensions(symbol, options)
quiet_left, quiet_right = options.fetch(:quiet_zone, true) ? symbol.quiet_zones : [0, 0]
modules = quiet_left + symbol.length + quiet_right
module_size = options[:width] ? options[:width] / modules.to_f : options.fetch(:module_size, DEFAULT_MODULE_SIZE)
bar_height = options[:height] || (DEFAULT_BAR_HEIGHT_MODULES * module_size)
text_size = options[:text_size] || (TEXT_SIZE_MODULES * module_size) if options[:text]
height = bar_height + (text_size ? (TEXT_GAP_MODULES * module_size) + text_size : 0)
[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
Expand All @@ -45,6 +94,16 @@ def draw_qr_code(symbol, options)

private

def bar_path(symbol, left, bottom, module_size, height)
path = +""
x = left
symbol.widths.each_with_index do |width, index|
path << PDF::Core.real_params([x, bottom, width * module_size, height]) << " re\n" if index.even?
x += width * module_size
end
path
end

# Dark modules side by side on a row are one rectangle of the path.
def module_path(symbol, left, top, module_size)
path = +""
Expand Down
182 changes: 182 additions & 0 deletions lib/gamba/barcodes/linear.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# frozen_string_literal: true

module Gamba
module Barcodes
# A linear barcode as the widths of its bars and spaces, in modules, from the first bar: Code 128 (sets A, B
# and C chosen as the text goes), EAN-13, EAN-8, Code 39 and ITF-14. Check digits are added when left out,
# and checked when given.
class Linear
TYPES = %i[code128 ean13 ean8 code39 itf14].freeze
# Light margins scanners need, in modules, before and after the bars.
QUIET_ZONES = {code128: [10, 10], ean13: [11, 7], ean8: [7, 7], code39: [10, 10], itf14: [10, 10]}.freeze
WIDE = 3

CODE128_PATTERNS = %w[
212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 221312 231212 112232 122132 122231 113222
123122 123221 223211 221132 221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 212123 212321
232121 111323 131123 131321 112313 132113 132311 211313 231113 231311 112133 112331 132131 113123 113321 133121
313121 211331 231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 314111 221411 431111 111224
111422 121124 121421 141122 141221 112214 112412 122114 122411 142112 142211 241211 221114 413111 241112 134111
111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 214121 412121 111143 111341 131141 114113
114311 411113 411311 113141 114131 311141 411131 211412 211214 211232 2331112
].map { |pattern| pattern.each_char.map(&:to_i).freeze }.freeze
CODE128_START = {a: 103, b: 104, c: 105}.freeze
CODE128_SWITCH = {a: 101, b: 100, c: 99}.freeze
CODE128_STOP = 106
# Digits worth a switch to set C: fewer cost more than they save.
CODE128_DIGITS_FOR_C = 4

EAN_L = %w[0001101 0011001 0010011 0111101 0100011 0110001 0101111 0111011 0110111 0001011].freeze
EAN_G = %w[0100111 0110011 0011011 0100001 0011101 0111001 0000101 0010001 0001001 0010111].freeze
EAN_R = %w[1110010 1100110 1101100 1000010 1011100 1001110 1010000 1000100 1001000 1110100].freeze
# Which left digits of an EAN-13 take the G patterns, by its first digit, which no pattern encodes.
EAN13_PARITY = %w[LLLLLL LLGLGG LLGGLG LLGGGL LGLLGG LGGLLG LGGGLL LGLGLG LGLGGL LGGLGL].freeze

CODE39 = {
"0" => "nnnwwnwnn", "1" => "wnnwnnnnw", "2" => "nnwwnnnnw", "3" => "wnwwnnnnn", "4" => "nnnwwnnnw",
"5" => "wnnwwnnnn", "6" => "nnwwwnnnn", "7" => "nnnwnnwnw", "8" => "wnnwnnwnn", "9" => "nnwwnnwnn",
"A" => "wnnnnwnnw", "B" => "nnwnnwnnw", "C" => "wnwnnwnnn", "D" => "nnnnwwnnw", "E" => "wnnnwwnnn",
"F" => "nnwnwwnnn", "G" => "nnnnnwwnw", "H" => "wnnnnwwnn", "I" => "nnwnnwwnn", "J" => "nnnnwwwnn",
"K" => "wnnnnnnww", "L" => "nnwnnnnww", "M" => "wnwnnnnwn", "N" => "nnnnwnnww", "O" => "wnnnwnnwn",
"P" => "nnwnwnnwn", "Q" => "nnnnnnwww", "R" => "wnnnnnwwn", "S" => "nnwnnnwwn", "T" => "nnnnwnwwn",
"U" => "wwnnnnnnw", "V" => "nwwnnnnnw", "W" => "wwwnnnnnn", "X" => "nwnnwnnnw", "Y" => "wwnnwnnnn",
"Z" => "nwwnwnnnn", "-" => "nwnnnnwnw", "." => "wwnnnnwnn", " " => "nwwnnnwnn", "$" => "nwnwnwnnn",
"/" => "nwnwnnnwn", "+" => "nwnnnwnwn", "%" => "nnnwnwnwn", "*" => "nwnnwnwnn"
}.transform_values { |pattern| pattern.each_char.map { (_1 == "w") ? WIDE : 1 }.freeze }.freeze

ITF = %w[nnwwn wnnnw nwnnw wwnnn nnwnw wnwnn nwwnn nnnww wnnwn nwnwn].map do |pattern|
pattern.each_char.map { (_1 == "w") ? WIDE : 1 }.freeze
end.freeze
ITF_START = [1, 1, 1, 1].freeze
ITF_STOP = [WIDE, 1, 1].freeze

attr_reader :type, :widths, :text

def initialize(data, type:)
raise ArgumentError, "type is #{type.inspect}: accepted types are #{TYPES.map(&:inspect).join(", ")}" unless TYPES.include?(type)

@type = type
@text = data.to_s
@widths = send(type)
end

def quiet_zones
QUIET_ZONES.fetch(@type)
end

# Modules from the first bar to the end of the last.
def length
@widths.sum
end

private

def code128
raise ArgumentError, "Code 128 encodes ASCII: #{@text.inspect} is not" unless @text.ascii_only?
raise ArgumentError, "Code 128 needs at least one character" if @text.empty?

values = code128_values(@text)
checksum = values.each_with_index.sum { |value, position| value * [position, 1].max } % 103
(values + [checksum, CODE128_STOP]).flat_map { CODE128_PATTERNS[_1] }
end

# Set C for runs of digits long enough to pay for the switch, A for control characters, B otherwise.
def code128_values(text)
values = []
set = nil
position = 0
while position < text.size
digits = text[position..][/\A\d+/].to_s.size
if digits >= CODE128_DIGITS_FOR_C || (digits >= 2 && digits == text.size - position && (set.nil? || set == :c))
if digits.odd?
set = code128_switch(values, set, code128_set_for(text[position]))
values << code128_value(text[position], set)
position += 1
digits -= 1
end
set = code128_switch(values, set, :c)
text[position, digits].scan(/\d\d/) { values << _1.to_i }
position += digits
else
character = text[position]
set = code128_switch(values, set, code128_set_for(character)) unless set && set != :c && code128_value(character, set)
values << code128_value(character, set)
position += 1
end
end
values
end

def code128_set_for(character)
(character.ord < 32) ? :a : :b
end

def code128_switch(values, current, wanted)
return current if current == wanted

values << (current.nil? ? CODE128_START : CODE128_SWITCH).fetch(wanted)
wanted
end

def code128_value(character, set)
code = character.ord
if set == :a
return code + 64 if code < 32

code - 32 if code < 96
elsif set == :b
code - 32 if code >= 32
end
end

def ean13
digits = check_digits(13)
parity = EAN13_PARITY[digits.first]
left = digits[1, 6].each_with_index.map { |digit, index| ((parity[index] == "G") ? EAN_G : EAN_L)[digit] }
modules("101" + left.join + "01010" + digits[7, 6].map { EAN_R[_1] }.join + "101")
end

def ean8
digits = check_digits(8)
modules("101" + digits[0, 4].map { EAN_L[_1] }.join + "01010" + digits[4, 4].map { EAN_R[_1] }.join + "101")
end

def code39
text = @text = @text.upcase
unknown = text.each_char.reject { CODE39.key?(_1) && _1 != "*" }
raise ArgumentError, "Code 39 encodes 0-9, A-Z, space and - . $ / + %: #{unknown.uniq.join.inspect} is not among them" unless unknown.empty?

"*#{text}*".each_char.flat_map { CODE39.fetch(_1) + [1] }[0...-1]
end

def itf14
digits = check_digits(14)
pairs = digits.each_slice(2).flat_map { |bars, spaces| ITF[bars].zip(ITF[spaces]).flatten! }
ITF_START + pairs + ITF_STOP
end

# The digits of a GTIN-like number of `count` digits, its check digit added when it lacks one: weights 3 and 1
# from the right (GS1 General Specifications §7.9).
def check_digits(count)
raise ArgumentError, "#{@type.to_s.upcase} encodes #{count - 1} digits and a check digit: #{@text.inspect} is not that" unless @text.match?(/\A\d{#{count - 1},#{count}}\z/)

digits = @text.each_char.map(&:to_i)
check = check_digit(digits.first(count - 1))
raise ArgumentError, "#{@text} ends in #{digits.last}, but its check digit is #{check}" if digits.size == count && digits.last != check

@text = (digits.first(count - 1) + [check]).join
digits.first(count - 1) + [check]
end

def check_digit(digits)
sum = digits.reverse.each_with_index.sum { |digit, index| index.even? ? digit * 3 : digit }
(10 - (sum % 10)) % 10
end

# "1011…" as widths of runs, from the first bar.
def modules(bits)
bits.scan(/1+|0+/).map(&:size)
end
end
end
end
42 changes: 42 additions & 0 deletions lib/gamba/native/layout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,43 @@ def description
end
end

# A linear barcode as a box: as wide as told, quiet zones included, or as many points per module as
# module_size says; never wider than its box.
class Barcode < Node
OPTIONS = (Node::OPTIONS + %i[type module_size height text text_size color quiet_zone align]).freeze

def initialize(data, type:, align: :left, **options)
raise ArgumentError, "align is #{align.inspect}: accepted alignments are #{Image::ALIGNMENTS.map(&:inspect).join(", ")}" unless Image::ALIGNMENTS.include?(align)

super(**options.slice(*Node::OPTIONS))
@symbol = Gamba::Barcodes::Linear.new(data, type:)
@options = options.except(*Node::OPTIONS)
@align = align
end

def paint(prawn, left, top, width)
options = drawn(width)
offset = {left: 0, center: (width - options[:width]) / 2.0, right: width - options[:width]}.fetch(@align)
prawn.draw_barcode(@symbol, at: relative(prawn, left + offset, top), **options)
end

private

def measure(_prawn, width)
Prawn::Barcodes.linear_dimensions(@symbol, drawn(width)).last
end

# The options to draw it with: its width at most the box's.
def drawn(available_width)
natural = Prawn::Barcodes.linear_dimensions(@symbol, {width: width}.compact.merge(@options))[4]
@options.merge(width: [natural, available_width].min)
end

def description
"a barcode"
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
Expand All @@ -341,6 +378,11 @@ def image(source, **options)
add(Image.new(source, **options))
end

def barcode(data, **options)
Styles.check_options(options, Barcode::OPTIONS)
add(Barcode.new(data, **options))
end

def qr_code(text, **options)
Styles.check_options(options, QRCode::OPTIONS)
add(QRCode.new(text, **options))
Expand Down
Loading
Loading