swiss_qr 0.1.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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ # Turns a Swiss QR Code payload into a Bill. Use SwissQr.parse.
5
+ class Parser
6
+ include Validation
7
+
8
+ BOM = "\uFEFF"
9
+ # Encodings that QR decoders commonly (and wrongly, for coding type 1)
10
+ # tag byte-mode segments with; valid UTF-8 bytes in them are UTF-8.
11
+ MISTAGGED_ENCODINGS = [Encoding::ISO_8859_1, Encoding::Windows_1252].freeze
12
+ # Header lines as parse recognizes them (blanks around SPC and the
13
+ # version are tolerated here so that looks_like_qr_bill? never skips what parse
14
+ # would report as an invalid QR-bill).
15
+ HEADER = /\A(?:\xEF\xBB\xBF)?[ \t]*SPC[ \t]*\r?\n[ \t]*\d{4}[ \t]*\r?\n/n
16
+
17
+ def self.parse(payload, strict: true)
18
+ new(payload).parse(strict: strict)
19
+ end
20
+
21
+ # Cheap check whether +text+ looks like a QR-bill payload ("SPC", a
22
+ # line break, a four-digit version and another line break).
23
+ def self.looks_like_qr_bill?(text)
24
+ return false unless text.respond_to?(:to_str)
25
+
26
+ text = text.to_str
27
+ text.b.match?(HEADER) || (!text.encoding.ascii_compatible? && utf8_bytes(text).match?(HEADER))
28
+ end
29
+
30
+ # The UTF-8 bytes of a UTF-16/UTF-32 string, for the byte-level match.
31
+ def self.utf8_bytes(text)
32
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).b
33
+ rescue EncodingError
34
+ text.b
35
+ end
36
+ private_class_method :utf8_bytes
37
+
38
+ def initialize(payload)
39
+ raise TypeError, "payload must be a String (got #{payload.class})" unless payload.respond_to?(:to_str)
40
+
41
+ @payload = payload.to_str
42
+ @errors = []
43
+ @warnings = []
44
+ end
45
+
46
+ def parse(strict: true)
47
+ text = utf8(@payload)
48
+ @raw = text.frozen? ? text : text.dup.freeze
49
+ check_input_size!(text)
50
+ text = strip_bom(text)
51
+ lines = split_lines(text)
52
+ check_structure!(lines)
53
+ values = read_values(lines)
54
+ bill = build_bill(values)
55
+ raise InvalidPayload.new(bill.errors, bill: bill) if strict && !bill.valid?
56
+
57
+ bill
58
+ end
59
+
60
+ private
61
+
62
+ def utf8(text)
63
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
64
+
65
+ text = text.dup
66
+ if [Encoding::BINARY, Encoding::US_ASCII, Encoding::UTF_8].include?(text.encoding)
67
+ text.force_encoding(Encoding::UTF_8)
68
+ elsif mistagged_utf8?(text)
69
+ warning(:payload, "payload tagged #{text.encoding} is valid UTF-8 and was read as UTF-8 (coding type 1)")
70
+ text.force_encoding(Encoding::UTF_8)
71
+ else
72
+ begin
73
+ text = text.encode(Encoding::UTF_8)
74
+ rescue EncodingError
75
+ text.force_encoding(Encoding::UTF_8)
76
+ end
77
+ end
78
+ return text if text.valid_encoding?
79
+
80
+ error(:payload, "payload is not valid UTF-8")
81
+ text.scrub("\uFFFD")
82
+ end
83
+
84
+ # QR byte segments without an ECI are ISO-8859-1 by default, so many
85
+ # decoders tag a QR-bill's UTF-8 bytes that way; transcoding them would
86
+ # turn "ö" into "ö".
87
+ def mistagged_utf8?(text)
88
+ MISTAGGED_ENCODINGS.include?(text.encoding) && !text.ascii_only? &&
89
+ text.dup.force_encoding(Encoding::UTF_8).valid_encoding?
90
+ end
91
+
92
+ def strip_bom(text)
93
+ return text unless text.start_with?(BOM)
94
+
95
+ warning(:payload, "byte order mark (BOM) removed")
96
+ text.delete_prefix(BOM)
97
+ end
98
+
99
+ # Drops the final line break and trailing empty (or blank) lines that
100
+ # many generators (SIX's own samples included) leave behind, then splits
101
+ # on CR LF or LF and checks the separators between the remaining lines.
102
+ def split_lines(text)
103
+ body = text.sub(/(?:\r\n|\n|\r)\s*\z/, "")
104
+ @crlf = body.include?("\r\n")
105
+ if @crlf && body.match?(/(?<!\r)\n/)
106
+ error(:payload, "CR LF and LF line separators are mixed; IG 4.1.4 requires the same separator throughout")
107
+ end
108
+ error(:payload, "a lone CR is not a permitted line separator (use CR LF or LF)") if body.match?(/\r(?!\n)/)
109
+
110
+ body.split(/\r?\n/, -1)
111
+ end
112
+
113
+ def check_structure!(lines)
114
+ header = lines.first&.strip
115
+ if header != QR_TYPE
116
+ message = header.nil? ? "empty payload" : %(QR type must be "#{QR_TYPE}" (got #{header[0, 20].inspect}))
117
+ raise InvalidPayload, @errors + [Problem.new(field: :header, message: message)]
118
+ end
119
+ return if lines.size >= MIN_LINES
120
+
121
+ message = "only #{lines.size} lines (not counting trailing empty lines); " \
122
+ "a QR-bill has #{MIN_LINES} to #{MAX_LINES} lines ending with the trailer #{TRAILER.inspect}"
123
+ raise InvalidPayload, @errors + [Problem.new(field: :payload, message: message)]
124
+ end
125
+
126
+ # Checks character set, padding and overall size; returns the stripped
127
+ # line values with empty lines as nil. Lines beyond MAX_LINES are
128
+ # reported by check_size and otherwise ignored.
129
+ def read_values(lines)
130
+ check_size(lines)
131
+ lines = lines.first(MAX_LINES)
132
+ fields = line_fields(lines)
133
+ lines.each_with_index.map do |line, index|
134
+ field = fields.fetch(index, :payload)
135
+ status_a = index >= MIN_LINES
136
+ check_characters(field, line, status_a: status_a)
137
+ value = line.strip
138
+ check_padding(field, line, value, status_a: status_a) if value != line
139
+ value.empty? ? nil : value.freeze
140
+ end
141
+ end
142
+
143
+ def check_size(lines)
144
+ if lines.size > MAX_LINES
145
+ error(:payload, "#{lines.size} lines; at most #{MAX_LINES} are permitted (31 + billing information + " \
146
+ "2 alternative procedures), the extra lines are ignored")
147
+ end
148
+ length = lines.sum(&:length) + lines.size - 1 # separators count as one character each (LF)
149
+ length += lines.size - 1 if @crlf
150
+ return if length <= MAX_PAYLOAD_LENGTH
151
+
152
+ error(:payload, "payload is #{length} characters including separators; at most #{MAX_PAYLOAD_LENGTH} are permitted")
153
+ end
154
+
155
+ def line_fields(lines)
156
+ %i[header version coding_type iban] +
157
+ address_fields(:creditor, lines[4]&.strip) +
158
+ address_fields(:ultimate_creditor, lines[11]&.strip) +
159
+ %i[amount currency] +
160
+ address_fields(:debtor, lines[20]&.strip) +
161
+ %i[reference_type reference unstructured_message trailer billing_information
162
+ alternative_procedures alternative_procedures]
163
+ end
164
+
165
+ # Fields are validated in line order so that errors come out in that order.
166
+ def build_bill(values)
167
+ check_header(values[1], values[2])
168
+ attributes = { version: values[1], coding_type: values[2] }
169
+ attributes.merge!(parties_and_amount(values))
170
+ attributes.merge!(reference_attributes(values, attributes))
171
+ attributes.merge!(additional_information(values, attributes[:amount]))
172
+ Bill.new(**attributes, errors: @errors.dup.freeze, warnings: @warnings.dup.freeze, raw: @raw)
173
+ end
174
+
175
+ def parties_and_amount(values)
176
+ iban = values[3] && Iban.normalize(values[3]).freeze
177
+ check_iban(values[3], iban)
178
+ {
179
+ iban: iban,
180
+ qr_iban: !iban.nil? && Iban.qr_iid?(iban),
181
+ creditor: build_address(:creditor, values[4, 7], required: true),
182
+ ultimate_creditor: build_ultimate_creditor(values[11, 7]),
183
+ amount: parse_amount(values[18]),
184
+ currency: values[19].tap { |currency| check_currency(currency) },
185
+ debtor: build_address(:debtor, values[20, 7], required: false)
186
+ }
187
+ end
188
+
189
+ def reference_attributes(values, attributes)
190
+ reference_type = parse_reference_type(values[27])
191
+ check_iban_reference_type(attributes[:iban], attributes[:qr_iban], reference_type, attributes[:currency])
192
+ check_reference(reference_type, values[28])
193
+ { reference_type: reference_type, reference: values[28] }
194
+ end
195
+
196
+ def additional_information(values, amount)
197
+ message, trailer, raw_billing_information = values[29, 3]
198
+ alternative_procedures = values[32, 2] || []
199
+ check_notification(amount, message)
200
+ check_trailer(trailer)
201
+ check_additional_information(message, raw_billing_information)
202
+ billing_information = parse_billing_information(raw_billing_information)
203
+ check_alternative_procedures(alternative_procedures)
204
+ {
205
+ unstructured_message: message, trailer: trailer, raw_billing_information: raw_billing_information,
206
+ billing_information: billing_information,
207
+ alternative_procedures: alternative_procedures.compact.freeze
208
+ }
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ # QR code readers and SwissQr.read / SwissQr.read_all.
4
+ module SwissQr
5
+ # Pluggable QR code readers: turn an image or PDF into the texts of the QR
6
+ # codes on it. SwissQr.read and SwissQr.read_all use SwissQr.reader, which
7
+ # defaults to the +qr_reader+ gem; any other decoder can be plugged in.
8
+ #
9
+ # A reader is either
10
+ # - an object responding to <tt>decode_all(input)</tt> and returning an
11
+ # Array of Strings (the +QrReader+ module itself qualifies), or
12
+ # - a callable (Proc, Method, any object with #call) returning a String,
13
+ # an Array of Strings, or nil when nothing was found.
14
+ #
15
+ # Elements may also be result objects with a #text method (such as
16
+ # QrReader::Result); nil elements are dropped.
17
+ #
18
+ # The input handed to a reader is never image bytes in a String: a String
19
+ # is a file path (image bytes come as an IO such as StringIO.new(bytes)).
20
+ module Reader
21
+ # Leading bytes of the image formats a String of image bytes most likely
22
+ # has; together with NUL bytes (never in a path) they reveal image data
23
+ # passed where a path belongs.
24
+ IMAGE_MAGIC = ["\x89PNG".b, "\xFF\xD8\xFF".b, "GIF8".b, "%PDF-".b, "II*\0".b, "MM\0*".b,
25
+ "\0\0\0\x0CjP".b, "\xFF\x4F\xFF\x51".b].freeze
26
+
27
+ INSTALL_HINT = <<~HINT.tr("\n", " ").strip
28
+ Reading QR codes from images and PDFs needs the qr_reader gem: run
29
+ "gem install qr_reader" or "bundle add qr_reader", or plug in another
30
+ decoder with SwissQr.reader = ->(input) { ... } (or pass reader: to
31
+ SwissQr.read).
32
+ HINT
33
+
34
+ # The default reader: the +qr_reader+ gem, required on first use (not
35
+ # when swiss_qr is loaded). Raises ReaderUnavailable when it cannot be
36
+ # loaded; the next call tries again.
37
+ #
38
+ # +options+ are passed on to qr_reader (<tt>try_harder:</tt>,
39
+ # <tt>inverted:</tt>):
40
+ #
41
+ # SwissQr.reader = SwissQr::Reader::QrReaderAdapter.new(try_harder: false)
42
+ class QrReaderAdapter
43
+ # The oldest qr_reader this adapter works with. Older releases sniff
44
+ # Strings (image bytes or path), raise for PDFs without any code, and
45
+ # crash ruby.wasm on PNGs.
46
+ MINIMUM_QR_READER = "0.0.3"
47
+
48
+ attr_reader :options
49
+
50
+ def initialize(**options)
51
+ @options = options.freeze
52
+ end
53
+
54
+ def decode_all(input)
55
+ reader = qr_reader
56
+ reader.decode_all(input, **options)
57
+ end
58
+
59
+ # Yields the text of each code as soon as it is decoded, so that
60
+ # SwissQr.read can stop at the first QR-bill of a long PDF.
61
+ def each_text(input, &)
62
+ reader = qr_reader
63
+ reader.scan(input, **options) { |result| yield result.text }
64
+ end
65
+
66
+ def inspect
67
+ shown = options.empty? ? "" : " #{options.inspect}"
68
+ "#<#{self.class.name}#{shown} (qr_reader gem, loaded on first use)>"
69
+ end
70
+
71
+ private
72
+
73
+ def qr_reader
74
+ begin
75
+ require "qr_reader"
76
+ rescue LoadError => e
77
+ raise ReaderUnavailable, "#{INSTALL_HINT} (#{e.message})"
78
+ end
79
+ check_version(::QrReader::VERSION)
80
+ ::QrReader
81
+ end
82
+
83
+ # Compared without Gem::Version, so that this also works where
84
+ # RubyGems is not loaded (ruby.wasm, --disable-gems).
85
+ def check_version(version)
86
+ numbers = ->(v) { v.to_s.split(".").map(&:to_i) }
87
+ return if (numbers.call(version) <=> numbers.call(MINIMUM_QR_READER)) >= 0
88
+
89
+ raise ReaderUnavailable, "qr_reader #{version} is too old: swiss_qr needs qr_reader " \
90
+ "#{MINIMUM_QR_READER} or newer (gem update qr_reader / bundle update qr_reader)"
91
+ end
92
+ end
93
+
94
+ # Wraps any supported reader in an object answering #decode_all and
95
+ # #each_text with plain Strings.
96
+ class Adapter
97
+ attr_reader :reader
98
+
99
+ def initialize(reader)
100
+ @reader = reader
101
+ end
102
+
103
+ def decode_all(input)
104
+ texts = []
105
+ each_text(input) { |text| texts << text }
106
+ texts
107
+ end
108
+
109
+ def each_text(input, &)
110
+ return reader.each_text(input, &) if reader.is_a?(QrReaderAdapter)
111
+
112
+ result = reader.respond_to?(:decode_all) ? reader.decode_all(input) : reader.call(input)
113
+ Reader.texts(result).each(&)
114
+ end
115
+ end
116
+
117
+ module_function
118
+
119
+ # Refuses image bytes passed as a String, where a path belongs: a String
120
+ # input is always a file path. Without this, bytes from a client that
121
+ # spell a path on the server would be read from disk.
122
+ def check_input(input)
123
+ return input unless input.is_a?(String)
124
+
125
+ bytes = input.b
126
+ return input if !bytes.include?("\0") && File.file?(input)
127
+ if bytes.include?("\0") || IMAGE_MAGIC.any? { |magic| bytes.start_with?(magic) }
128
+ raise ArgumentError, "expected a file path, got a String that looks like image data " \
129
+ "(#{bytes.bytesize} bytes); pass image bytes as an IO: StringIO.new(bytes)"
130
+ end
131
+
132
+ input
133
+ end
134
+
135
+ # True when +reader+ can be used as a reader.
136
+ def reader?(reader)
137
+ reader.respond_to?(:decode_all) || reader.respond_to?(:call)
138
+ end
139
+
140
+ # Wraps +reader+ (see Reader) in an Adapter; nil means SwissQr.reader.
141
+ def adapt(reader)
142
+ reader = SwissQr.reader if reader.nil?
143
+ return reader if reader.is_a?(Adapter)
144
+ raise ArgumentError, "a reader must respond to #decode_all or #call (got #{reader.inspect})" unless reader?(reader)
145
+
146
+ Adapter.new(reader)
147
+ end
148
+
149
+ # Normalises what a reader returned into an Array of Strings. A single
150
+ # String or result object (with #text) counts as one code; it is not
151
+ # splatted with Array() (a Struct result would turn into its fields).
152
+ def texts(result)
153
+ items = result.respond_to?(:to_str) || result.respond_to?(:text) ? [result] : Array(result)
154
+ items.filter_map { |item| text(item) }
155
+ end
156
+
157
+ def text(item)
158
+ return if item.nil?
159
+ return item.to_str if item.respond_to?(:to_str)
160
+ return item.text&.to_s if item.respond_to?(:text)
161
+
162
+ raise TypeError, "a reader must return Strings (or objects with #text), got #{item.class}"
163
+ end
164
+ end
165
+
166
+ @reader = nil
167
+ @reader_lock = Mutex.new
168
+
169
+ class << self
170
+ # The reader used by read and read_all: what was assigned with reader=,
171
+ # or else a Reader::QrReaderAdapter for the +qr_reader+ gem (which is
172
+ # only required when a code is actually read).
173
+ def reader
174
+ @reader || @reader_lock.synchronize { @reader ||= Reader::QrReaderAdapter.new }
175
+ end
176
+
177
+ # Replaces the reader for the whole process: an object with
178
+ # <tt>decode_all(input)</tt> or a callable (see Reader). +nil+ restores
179
+ # the default (+qr_reader+).
180
+ #
181
+ # SwissQr.reader = ->(path) { `zbarimg --quiet --raw #{path}`.split("\n") }
182
+ def reader=(reader)
183
+ unless reader.nil? || Reader.reader?(reader)
184
+ raise ArgumentError, "a reader must respond to #decode_all or #call (got #{reader.inspect})"
185
+ end
186
+
187
+ @reader_lock.synchronize { @reader = reader }
188
+ end
189
+
190
+ # Reads the QR codes in +input+ - a file path (String or Pathname), an
191
+ # IO with the image or PDF (StringIO.new(bytes), an upload's Tempfile),
192
+ # or whatever else the reader accepts, such as a ChunkyPNG image - and
193
+ # returns the first QR-bill as a Bill, or nil when there is none. A
194
+ # String is always a path: image bytes in a String raise ArgumentError.
195
+ # Codes that are not QR-bills (see looks_like_qr_bill?) are skipped. +strict+ is
196
+ # passed on to parse.
197
+ def read(input, reader: self.reader, strict: true)
198
+ Reader.adapt(reader).each_text(Reader.check_input(input)) do |text|
199
+ return parse(text, strict: strict) if looks_like_qr_bill?(text)
200
+ end
201
+ nil
202
+ end
203
+
204
+ # Like read, but returns every QR-bill found (all pages), in the order
205
+ # the reader reported them; an empty Array when there is none.
206
+ def read_all(input, reader: self.reader, strict: true)
207
+ texts = Reader.adapt(reader).decode_all(Reader.check_input(input))
208
+ texts.select { |text| looks_like_qr_bill?(text) }.map { |text| parse(text, strict: strict) }
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ # Payment reference helpers: the 27-digit QR reference (QRR, used with a
5
+ # QR-IBAN) and the ISO 11649 creditor reference (SCOR, "RF..." with an
6
+ # ordinary IBAN).
7
+ #
8
+ # All predicates ignore whitespace, so display-formatted references are
9
+ # accepted. The parser itself is stricter and rejects spaces in a payload.
10
+ module Reference
11
+ QRR_PATTERN = /\A\d{27}\z/
12
+ SCOR_PATTERN = /\ARF\d{2}[A-Z0-9]{1,21}\z/
13
+
14
+ module_function
15
+
16
+ def normalize(reference)
17
+ reference.to_s.gsub(/[[:space:]]/, "")
18
+ end
19
+
20
+ # True for 27 digits with a correct modulo-10 recursive check digit that
21
+ # are not all zeros (IG 2.3).
22
+ def valid_qrr?(reference)
23
+ value = normalize(reference)
24
+ value.match?(QRR_PATTERN) && !value.match?(/\A0+\z/) &&
25
+ Checksum.mod10_recursive(value[0, 26]) == value[26].to_i
26
+ end
27
+
28
+ # True for a 5-25 character creditor reference "RF" + 2 check digits +
29
+ # up to 21 alphanumerics with valid MOD 97-10 check digits. Case-insensitive.
30
+ def valid_scor?(reference)
31
+ value = normalize(reference).upcase
32
+ value.match?(SCOR_PATTERN) && Checksum.mod97(value) == 1
33
+ end
34
+
35
+ # Appends the check digit to up to 26 digits (left-padded with zeros):
36
+ # <tt>build_qrr("21000000000313947143000901")</tt> => "210000000003139471430009017".
37
+ def build_qrr(digits)
38
+ body = normalize(digits)
39
+ raise ArgumentError, "QR reference body must be 1-26 digits" unless body.match?(/\A\d{1,26}\z/)
40
+
41
+ body = body.rjust(26, "0")
42
+ "#{body}#{Checksum.mod10_recursive(body)}"
43
+ end
44
+
45
+ # Builds a creditor reference from up to 21 alphanumerics:
46
+ # <tt>build_scor("539007547034")</tt> => "RF18539007547034".
47
+ def build_scor(payload)
48
+ body = normalize(payload).upcase
49
+ raise ArgumentError, "creditor reference body must be 1-21 alphanumerics" unless body.match?(/\A[A-Z0-9]{1,21}\z/)
50
+
51
+ check = 98 - Checksum.mod97("RF00#{body}")
52
+ Kernel.format("RF%<check>02d%<body>s", check: check, body: body)
53
+ end
54
+
55
+ # Display form of a QR reference: "21 00000 00003 13947 14300 09017"
56
+ # (groups of five from the right).
57
+ def format_qrr(reference)
58
+ value = normalize(reference)
59
+ value.reverse.scan(/.{1,5}/).join(" ").reverse
60
+ end
61
+
62
+ # Display form of a creditor reference: "RF18 5390 0754 7034".
63
+ def format_scor(reference)
64
+ normalize(reference).upcase.scan(/.{1,4}/).join(" ")
65
+ end
66
+
67
+ # Display form chosen by shape (QRR if all digits, otherwise SCOR style).
68
+ def format(reference, type = nil)
69
+ return nil if reference.nil?
70
+
71
+ type ||= normalize(reference).match?(/\A\d+\z/) ? :qrr : :scor
72
+ case type
73
+ when :qrr then format_qrr(reference)
74
+ when :scor then format_scor(reference)
75
+ else reference.to_s
76
+ end
77
+ end
78
+ end
79
+ end