tallyman 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Basically just an invoice. Stick a ton of line items in it, add some details, and then render it out!
6
+ class Invoice
7
+
8
+ include Tallyman::Invoiceable
9
+
10
+ attr_accessor :invoice_number, :bill_to, :ship_to, :notes, :line_items, :shipping_description,
11
+ :tax_description, :retention_description, :due_at, :paid_at, :refunded_at, :currency,
12
+ :invoice_details, :invoice_date, :qr_code
13
+
14
+ attr_reader :tax_rate, :shipping_rate, :retention_rate
15
+
16
+ # Options assigned as given; a missing one is nil, which the rate setters turn into 0.
17
+ OPTIONS = %i[invoice_number bill_to ship_to notes shipping_rate shipping_description tax_rate
18
+ tax_description retention_rate retention_description due_at paid_at refunded_at
19
+ currency invoice_date qr_code].freeze
20
+
21
+ def initialize(options = {})
22
+ OPTIONS.each { |name| public_send(:"#{name}=", options[name]) }
23
+ self.line_items = options[:line_items] || []
24
+ self.invoice_details = options[:invoice_details] || []
25
+ end
26
+
27
+ # The tax rate that we're applying, as a BigDecimal
28
+ def tax_rate=(value)
29
+ @tax_rate = Tallyman.to_decimal(value)
30
+ end
31
+
32
+ # Shipping rate
33
+ def shipping_rate=(value)
34
+ @shipping_rate = Tallyman.to_decimal(value)
35
+ end
36
+
37
+ # Retention rate (e.g. IRPF). Applied as a percentage of the subtotal and deducted from the total.
38
+ def retention_rate=(value)
39
+ @retention_rate = Tallyman.to_decimal(value)
40
+ end
41
+
42
+ # Adds a line item
43
+ def add_line_item(options = {})
44
+ line_items << Tallyman::LineItem.new(options)
45
+ end
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Turns an Invoiceable into the plain Hash the Typst template consumes.
6
+ #
7
+ # Every currency, date and translation decision happens here, so the template stays a pure
8
+ # layout concern and receives nothing but literal strings.
9
+ class InvoicePresenter
10
+
11
+ def initialize(invoice)
12
+ @invoice = invoice
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ page_size: setting(:page_size),
18
+ company_name: setting(:company_name).strip,
19
+ company_details: company_details,
20
+ stamp: stamp,
21
+ bill_to: @invoice.bill_to,
22
+ ship_to: ship_to,
23
+ labels: labels,
24
+ details: details,
25
+ line_items: line_items,
26
+ totals: totals,
27
+ notes: Markup.to_runs(@invoice.notes),
28
+ qr_code: qr_code
29
+ }
30
+ end
31
+
32
+ def line_items
33
+ @invoice.line_items.map { |line| line_item(line) }
34
+ end
35
+
36
+ def totals
37
+ [[t('invoice.subtotal', 'Subtotal:'), money(@invoice.subtotal), false],
38
+ [tax_label, money(@invoice.tax), false],
39
+ *shipping_row,
40
+ *retention_row,
41
+ [t('invoice.total', 'Total:'), money(@invoice.total), true]]
42
+ end
43
+
44
+ def stamp
45
+ return t('status.refunded', 'REFUNDED') if @invoice.refunded?
46
+ return t('status.paid', 'PAID') if @invoice.paid?
47
+
48
+ t('status.overdue', 'OVERDUE') if @invoice.overdue?
49
+ end
50
+
51
+ def details
52
+ rows = [*number_row, *due_row, *paid_row]
53
+ @invoice.each_detail { |key, value| rows << [key.to_s, value.to_s] }
54
+ rows
55
+ end
56
+
57
+ def labels
58
+ {
59
+ bill_to: t('invoice.bill_to', 'Bill To'),
60
+ ship_to: t('invoice.ship_to', 'Ship To'),
61
+ notes: t('invoice.notes', 'Notes'),
62
+ description: t('line_item.description', 'Description'),
63
+ unit_price: t('line_item.unit_price', 'Unit Price'),
64
+ quantity: t('line_item.quantity', 'Quantity'),
65
+ amount: t('line_item.amount', 'Amount')
66
+ }
67
+ end
68
+
69
+ private
70
+
71
+ def shipping_row
72
+ [[shipping_label, money(@invoice.shipping), false]] if @invoice.shipping_rate.positive?
73
+ end
74
+
75
+ def retention_row
76
+ [[retention_label, money(-@invoice.retention), false]] if @invoice.retention_rate.positive?
77
+ end
78
+
79
+ def number_row
80
+ [[number_label, @invoice.invoice_number.to_s]] if @invoice.invoice_number
81
+ end
82
+
83
+ def due_row
84
+ [[t('invoice.due_date', 'Due Date:'), date(@invoice.due_at)]] if @invoice.due_at
85
+ end
86
+
87
+ def paid_row
88
+ [[t('invoice.paid_date', 'Paid Date:'), date(@invoice.paid_at)]] if @invoice.paid_at
89
+ end
90
+
91
+ def qr_code
92
+ return nil unless @invoice.respond_to?(:qr_code) && @invoice.qr_code.to_s.strip.present?
93
+
94
+ @invoice.qr_code.to_s
95
+ end
96
+
97
+ def line_item(line)
98
+ return predefined_line_item(line) if line.predefined_amount
99
+
100
+ {description: Markup.to_runs(line.description),
101
+ price: line.display_price || money(line.price),
102
+ quantity: line.display_quantity || BigDecimal(line.quantity.to_s).to_s('F'),
103
+ amount: money(line.amount)}
104
+ end
105
+
106
+ def predefined_line_item(line)
107
+ {description: Markup.to_runs(line.description), price: '', quantity: '',
108
+ amount: money(line.predefined_amount)}
109
+ end
110
+
111
+ def tax_label
112
+ @invoice.tax_description || t('invoice.tax', 'Tax:')
113
+ end
114
+
115
+ def shipping_label
116
+ @invoice.shipping_description || t('invoice.shipping', 'Shipping:')
117
+ end
118
+
119
+ def retention_label
120
+ @invoice.retention_description || t('invoice.retention', 'Retention:')
121
+ end
122
+
123
+ def money(number)
124
+ PdfRenderer.number_to_currency(number, @invoice)
125
+ end
126
+
127
+ # Prawn rendered each line of company_details as its own table cell, which collapsed any
128
+ # leading indentation. Config is commonly set from an indented heredoc, so strip it here
129
+ # rather than let it show up in the PDF.
130
+ def company_details
131
+ setting(:company_details).to_s.lines.map(&:strip).join("\n").strip
132
+ end
133
+
134
+ def ship_to
135
+ @invoice.ship_to if @invoice.respond_to?(:ship_to)
136
+ end
137
+
138
+ def number_label
139
+ return t('invoice.receipt_no', 'Receipt #:') if @invoice.paid?
140
+
141
+ t('invoice.invoice_no', 'Invoice #:')
142
+ end
143
+
144
+ def date(value)
145
+ return value.to_s unless value.is_a?(Date) || value.is_a?(Time)
146
+
147
+ value.strftime(setting(:date_format))
148
+ end
149
+
150
+ def t(key, default)
151
+ I18n.t("tallyman.#{key}", default: default)
152
+ end
153
+
154
+ def setting(property)
155
+ Tallyman::Config.default.value_for(@invoice, property)
156
+ end
157
+
158
+ end
159
+
160
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Include {Tallyman::Invoiceable} in your Invoice class to make it Tallyman compatible. Tallyman
4
+ # expects that a +line_items+ method containing an Enumerable of {Tallyman::LineItem} compatible
5
+ # elements exists. Those LineItem objects primarily need to include quantity, price, and description methods.
6
+ #
7
+ # The +bill_to+ method should always be overwritten by your class. Otherwise, it'll say that your invoice should
8
+ # be billed to Goofy McGoofison. +ship_to+ is also available, but will not be used in rendered invoices if it
9
+ # doesn't exist.
10
+ #
11
+ # Although not required, if a +tax_rate+ method exists, {Tallyman::Invoiceable} will use it to calculate tax
12
+ # when generating an invoice. We include a simple tax method that calculates tax, but it's probably wiser
13
+ # to override this in your class (our calculated tax won't be stored to a database by default, for example).
14
+ #
15
+ # If the +due_at+, +paid_at+, and +refunded_at+ methods are available, {Tallyman::Invoiceable} will use them
16
+ # to show due dates, paid dates, and refunded dates, as well as stamps showing if the invoice is paid or due.
17
+ module Tallyman
18
+
19
+ module Invoiceable
20
+
21
+ # Who the invoice is being sent to. A +bill_to+ the class already has wins over this
22
+ # placeholder: an ActiveRecord column reader sits behind this module in the ancestors, so
23
+ # without the +super+ a saved +bill_to+ would never be read.
24
+ def bill_to
25
+ return super if defined?(super)
26
+
27
+ "Goofy McGoofison\nYour Invoice Doesn't\nHave It's Own BillTo Method"
28
+ end
29
+
30
+ # Calculates the subtotal of this invoice by adding up all of the line items
31
+ def subtotal
32
+ line_items.reduce(BigDecimal(0)) { |result, item| result + item.amount }
33
+ end
34
+
35
+ # The tax for this invoice, as a BigDecimal
36
+ def tax
37
+ defined?(tax_rate) ? subtotal * tax_rate / 100 : 0
38
+ end
39
+
40
+ # TODO: Add a per weight unit shipping cost
41
+ # Calculates the shipping
42
+ def shipping
43
+ defined?(shipping_rate) ? shipping_rate : 0
44
+ end
45
+
46
+ # Calculates the retention (e.g. IRPF) as a percentage of the subtotal.
47
+ # This is a post-tax deduction subtracted from the total.
48
+ def retention
49
+ defined?(retention_rate) ? subtotal * retention_rate / 100 : 0
50
+ end
51
+
52
+ # Calculates the total for this invoice.
53
+ def total
54
+ subtotal + tax + shipping - retention
55
+ end
56
+
57
+ def overdue?
58
+ defined?(due_at) && Invoiceable.past?(due_at) && !paid_at
59
+ end
60
+
61
+ # Whether a Date is before today or a Time before now. A module function rather than a
62
+ # private method, so it does not land in the classes that include Invoiceable.
63
+ def self.past?(moment)
64
+ case moment
65
+ when Date then moment < Date.today
66
+ when Time then moment < Time.now
67
+ else false
68
+ end
69
+ end
70
+
71
+ def refunded?
72
+ defined?(refunded_at) && !!refunded_at
73
+ end
74
+
75
+ def paid?
76
+ defined?(paid_at) && !!paid_at
77
+ end
78
+
79
+ # Renders this invoice to pdf as a string. Pass a Tallyman::Appendix to compile pages of
80
+ # your own onto the end of it.
81
+ def render_pdf(appendix: nil)
82
+ Tallyman::PdfRenderer.render(self, appendix: appendix)
83
+ end
84
+
85
+ # Renders this invoice to pdf
86
+ def render_pdf_to_file(path, appendix: nil)
87
+ Tallyman::PdfRenderer.render_to_file(self, path, appendix: appendix)
88
+ end
89
+
90
+ # Iterates through the details on this invoiceable. The block given should accept
91
+ # two parameters, the detail name and the actual detail value.
92
+ def each_detail
93
+ return unless defined?(invoice_details)
94
+
95
+ invoice_details.each { |detail| yield detail[0], detail[1] }
96
+ end
97
+
98
+ end
99
+
100
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Represents a line item in an invoice.
6
+ #
7
+ # +quantity+ and +price+ are written to be pretty picky, primarily because if we're not picky about what
8
+ # values are set to them your invoice math could get pretty messed up. It's recommended that both values be
9
+ # set to +BigDecimal+ values. Otherwise, we'll do our best to convert the set values to a +BigDecimal+.
10
+ class LineItem
11
+
12
+ include LineItemable
13
+
14
+ attr_accessor :description, :display_quantity, :display_price
15
+ attr_reader :quantity, :price, :predefined_amount
16
+
17
+ # Initializes a new LineItem
18
+ def initialize(options = {})
19
+ if options[:predefined_amount]
20
+ self.predefined_amount = options[:predefined_amount]
21
+ else
22
+ self.quantity = options[:quantity] || '1'
23
+ self.display_quantity = options[:display_quantity]
24
+ self.display_price = options[:display_price]
25
+ self.price = options[:price] || '0.00'
26
+ end
27
+ self.description = options[:description] || ''
28
+ end
29
+
30
+ # Sets the quantity of this {LineItem}
31
+ def quantity=(value)
32
+ @quantity = Tallyman.to_decimal(value)
33
+ end
34
+
35
+ # Sets the price for this {LineItem}
36
+ def price=(value)
37
+ @price = Tallyman.to_decimal(value)
38
+ end
39
+
40
+ # Sets the predefined_amount for this {LineItem}
41
+ def predefined_amount=(value)
42
+ @predefined_amount = Tallyman.to_decimal(value)
43
+ end
44
+
45
+ end
46
+
47
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Include this module into your line item implementation to make sure that Tallyman stays happy
4
+ # with it, or just make sure that your line item implements the amount method.
5
+ module Tallyman
6
+
7
+ module LineItemable
8
+
9
+ # Returns the total amount for this {LineItemable}, or +price * quantity+
10
+ def amount
11
+ return predefined_amount if predefined_amount
12
+
13
+ price * quantity
14
+ end
15
+
16
+ end
17
+
18
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Converts the Prawn +inline_format+ markup this gem used to accept in notes and line item
6
+ # descriptions into structured runs that the Typst template can style.
7
+ #
8
+ # We deliberately produce data rather than Typst source. The template renders each run's
9
+ # +text+ as a literal string, so a customer-supplied description can never become Typst
10
+ # code. Never change this to emit markup.
11
+ class Markup
12
+
13
+ # Tags that simply switch a style on, mapped to the key they set on a run.
14
+ FLAGS = {'b' => :bold, 'i' => :italic, 'u' => :underline, 'strikethrough' => :strike,
15
+ 'sub' => :sub, 'sup' => :sup}.freeze
16
+
17
+ TAG = %r{<(?<name>b|i|u|strikethrough|sub|sup|font|color|link)(?<attrs>[^>]*)>(?<body>.*?)</\k<name>>}m
18
+ LINE_BREAK = %r{<br\s*/?>}
19
+
20
+ # Returns an Array of Hashes, each with a :text key and any of :bold, :italic, :underline,
21
+ # :strike, :sub, :sup, :size, :color and :link. Returns nil when there is nothing to render.
22
+ def self.to_runs(text)
23
+ return nil if text.nil?
24
+
25
+ runs(text.to_s.gsub(LINE_BREAK, "\n"), {})
26
+ end
27
+
28
+ def self.runs(text, style)
29
+ result = []
30
+ position = 0
31
+
32
+ while (match = TAG.match(text, position))
33
+ append(result, style, text[position...match.begin(0)])
34
+ result.concat(runs(match[:body], style.merge(style_for(match[:name], match[:attrs]))))
35
+ position = match.end(0)
36
+ end
37
+
38
+ append(result, style, text[position..])
39
+ result
40
+ end
41
+ private_class_method :runs
42
+
43
+ def self.append(result, style, text)
44
+ result << style.merge(text: text) unless text.nil? || text.empty?
45
+ end
46
+ private_class_method :append
47
+
48
+ def self.style_for(name, attrs)
49
+ return {FLAGS[name] => true} if FLAGS.key?(name)
50
+
51
+ case name
52
+ when 'font' then {size: attrs[/size=['"](\d+)['"]/, 1].to_i}
53
+ when 'color' then {color: attrs[/rgb=['"]#?(\h{6})['"]/, 1]}
54
+ when 'link' then {link: attrs[/href=['"](.*?)['"]/, 1]}
55
+ end
56
+ end
57
+ private_class_method :style_for
58
+
59
+ end
60
+
61
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Renders an invoice to PDF by feeding a presented Hash to the Typst template.
6
+ #
7
+ # Invoice data crosses into the template only as sys_inputs JSON, where Typst treats every
8
+ # string as literal text. Never interpolate invoice data into the template source.
9
+ class PdfRenderer
10
+
11
+ TEMPLATE = File.expand_path('templates/invoice.typ', __dir__)
12
+ FONT_DIR = File.expand_path('../../fonts', __dir__)
13
+ FONTS = %w[NotoSans-Regular.ttf NotoSans-Bold.ttf].freeze
14
+
15
+ # Renders the given invoice as a pdf on disk
16
+ def self.render_to_file(invoice, path, appendix: nil)
17
+ File.binwrite(path, render(invoice, appendix: appendix))
18
+ end
19
+
20
+ # Renders the given invoice as a pdf, returning a string. See Tallyman::Appendix for
21
+ # appending pages of your own to it.
22
+ def self.render(invoice, appendix: nil)
23
+ new(invoice, appendix: appendix).render
24
+ end
25
+
26
+ # Converts this number to a formatted currency string
27
+ def self.number_to_currency(number, invoice)
28
+ Money.locale_backend = :currency
29
+ Money.rounding_mode = BigDecimal::ROUND_HALF_UP
30
+ currency = Money::Currency.wrap(Tallyman::Config.default.value_for(invoice, :currency))
31
+ number *= currency.subunit_to_unit
32
+ number = number.round unless Money.default_infinite_precision
33
+ Money.new(number, currency).format
34
+ end
35
+
36
+ def initialize(invoice, appendix: nil)
37
+ @invoice = invoice
38
+ @appendix = Appendix.wrap(appendix)
39
+ @dependencies = {}
40
+ end
41
+
42
+ def render
43
+ data = InvoicePresenter.new(@invoice).to_h
44
+ register_qr_code(data[:qr_code])
45
+ data[:logo] = logo
46
+
47
+ Typst(body: body, dependencies: dependencies, fonts: fonts, sys_inputs: sys_inputs(data))
48
+ .compile(:pdf).bytes.flatten.pack('C*')
49
+ end
50
+
51
+ private
52
+
53
+ # An appendix is concatenated rather than +#include+d, which would make it a module of its
54
+ # own and leave the template's page rules -- the margins, and the numbering footer --
55
+ # stopping at its edge.
56
+ def body
57
+ template = File.read(TEMPLATE)
58
+ return template if @appendix.nil?
59
+
60
+ "#{template}\n#{@appendix.source}"
61
+ end
62
+
63
+ def sys_inputs(data)
64
+ merge({'invoice' => data.to_json}, @appendix&.inputs, 'sys_input')
65
+ end
66
+
67
+ # Read after the logo and the QR code have registered theirs, so an appendix reusing one
68
+ # of those names is caught rather than silently replacing the file.
69
+ def dependencies
70
+ merge(@dependencies, @appendix&.dependencies, 'dependency')
71
+ end
72
+
73
+ def merge(ours, theirs, what)
74
+ return ours if theirs.nil?
75
+
76
+ taken = ours.keys & theirs.keys
77
+ if taken.any?
78
+ raise ArgumentError,
79
+ "appendix #{what} #{taken.map(&:inspect).join(', ')} is reserved by the invoice"
80
+ end
81
+
82
+ ours.merge(theirs)
83
+ end
84
+
85
+ def fonts
86
+ FONTS.to_h { |name| [name, File.binread(File.join(FONT_DIR, name))] }
87
+ end
88
+
89
+ # Config#invoice_logo is either a path or a {filename:, size: "WxH"} Hash.
90
+ def logo
91
+ setting = Tallyman::Config.default.value_for(@invoice, :invoice_logo)
92
+ return nil if setting.nil?
93
+
94
+ path, width, height = logo_parts(setting)
95
+ name = "logo#{File.extname(path.to_s)}"
96
+ @dependencies[name] = File.binread(path.to_s)
97
+ {name: name, width: width, height: height}
98
+ end
99
+
100
+ def logo_parts(setting)
101
+ return [setting, nil, nil] unless setting.is_a?(Hash)
102
+
103
+ width, height = setting[:size].to_s.split('x').map(&:to_f)
104
+ [setting[:filename], width, height]
105
+ end
106
+
107
+ def register_qr_code(data)
108
+ return if data.nil?
109
+
110
+ @dependencies['qr.svg'] = QrCode.new(data).to_svg
111
+ end
112
+
113
+ end
114
+
115
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rqrcode'
4
+
5
+ module Tallyman
6
+
7
+ # Draws a QR code as an SVG.
8
+ #
9
+ # The matrix comes straight from RQRCode, so the payload and its error correction level are
10
+ # untouched and the code carries exactly the same data it always did. Only the drawing
11
+ # changes: vector instead of a 200px raster, lightly rounded modules, and the three finder
12
+ # patterns drawn as rounded frames.
13
+ #
14
+ # Rounding is kept deliberately slight. These codes are scanned off paper, sometimes off a
15
+ # bad photocopy, and on a Verifactu invoice a code that will not scan is a compliance
16
+ # problem rather than a cosmetic one. Modules keep their full square footprint apart from a
17
+ # softened corner, so a scanner sees the same geometry the old raster produced.
18
+ class QrCode
19
+
20
+ QUIET_ZONE = 4 # modules of clear space; the QR spec's minimum
21
+ UNIT = 10 # user units per module
22
+ RADIUS = 1.5 # corner radius of a data module, out of UNIT
23
+ FINDER = 7 # finder patterns are 7 modules square
24
+ # Corner radius of a finder frame. Above roughly 1.2 modules the curve starts cutting the
25
+ # corner module out of the ring, which changes what a scanner reads.
26
+ EYE_RADIUS = 10
27
+ DARK = '#111111'
28
+ LIGHT = '#ffffff'
29
+
30
+ def initialize(data)
31
+ @modules = RQRCode::QRCode.new(data.to_s).modules
32
+ end
33
+
34
+ def to_svg
35
+ <<~SVG
36
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{side} #{side}" shape-rendering="geometricPrecision">
37
+ <rect width="#{side}" height="#{side}" fill="#{LIGHT}"/>
38
+ #{(data_modules + finders).join("\n")}
39
+ </svg>
40
+ SVG
41
+ end
42
+
43
+ private
44
+
45
+ def size
46
+ @modules.length
47
+ end
48
+
49
+ def side
50
+ (size + (QUIET_ZONE * 2)) * UNIT
51
+ end
52
+
53
+ def data_modules
54
+ @modules.each_with_index.flat_map do |cells, row|
55
+ cells.each_with_index.filter_map do |dark, col|
56
+ module_rect(row, col) if dark && !finder?(row, col)
57
+ end
58
+ end
59
+ end
60
+
61
+ def module_rect(row, col)
62
+ %(<rect class="m" x="#{x(col)}" y="#{x(row)}" width="#{UNIT}" height="#{UNIT}" ) +
63
+ %(rx="#{RADIUS}" fill="#{DARK}"/>)
64
+ end
65
+
66
+ # A finder is a 7x7 ring with a 3x3 centre. Drawing it as two rounded rects rather than 33
67
+ # separate modules is what gives the code its softer corners.
68
+ def finders
69
+ finder_origins.flat_map { |row, col| [ring(row, col), pupil(row, col)] }
70
+ end
71
+
72
+ def ring(row, col)
73
+ %(<rect class="f" x="#{x(col) + (UNIT / 2)}" y="#{x(row) + (UNIT / 2)}" ) +
74
+ %(width="#{UNIT * 6}" height="#{UNIT * 6}" rx="#{EYE_RADIUS}" ) +
75
+ %(fill="none" stroke="#{DARK}" stroke-width="#{UNIT}"/>)
76
+ end
77
+
78
+ def pupil(row, col)
79
+ %(<rect class="p" x="#{x(col + 2)}" y="#{x(row + 2)}" ) +
80
+ %(width="#{UNIT * 3}" height="#{UNIT * 3}" rx="#{UNIT}" fill="#{DARK}"/>)
81
+ end
82
+
83
+ def finder_origins
84
+ [[0, 0], [0, size - FINDER], [size - FINDER, 0]]
85
+ end
86
+
87
+ def finder?(row, col)
88
+ finder_origins.any? do |r, c|
89
+ row.between?(r, r + FINDER - 1) && col.between?(c, c + FINDER - 1)
90
+ end
91
+ end
92
+
93
+ def x(index)
94
+ (index + QUIET_ZONE) * UNIT
95
+ end
96
+
97
+ end
98
+
99
+ end