invoice_extractor_arca 0.2.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +631 -0
  5. data/exe/arca-invoice-extract +6 -0
  6. data/lib/invoice_extractor_arca/catalogs/copy_types.json +5 -0
  7. data/lib/invoice_extractor_arca/catalogs/currencies.json +65 -0
  8. data/lib/invoice_extractor_arca/catalogs/document_types.json +40 -0
  9. data/lib/invoice_extractor_arca/catalogs/iva_types.json +13 -0
  10. data/lib/invoice_extractor_arca/catalogs/payment_methods.json +10 -0
  11. data/lib/invoice_extractor_arca/catalogs/units_measure.json +47 -0
  12. data/lib/invoice_extractor_arca/catalogs/voucher_types.json +99 -0
  13. data/lib/invoice_extractor_arca/catalogs.rb +34 -0
  14. data/lib/invoice_extractor_arca/cli.rb +247 -0
  15. data/lib/invoice_extractor_arca/command_runner.rb +18 -0
  16. data/lib/invoice_extractor_arca/configuration.rb +42 -0
  17. data/lib/invoice_extractor_arca/dependencies/resolver.rb +118 -0
  18. data/lib/invoice_extractor_arca/error.rb +6 -0
  19. data/lib/invoice_extractor_arca/error_record.rb +39 -0
  20. data/lib/invoice_extractor_arca/errors.rb +34 -0
  21. data/lib/invoice_extractor_arca/extractor.rb +381 -0
  22. data/lib/invoice_extractor_arca/final_payload_builder.rb +67 -0
  23. data/lib/invoice_extractor_arca/ocr/rapidocr_extract.py +198 -0
  24. data/lib/invoice_extractor_arca/ocr/rapidocr_text_extractor.rb +117 -0
  25. data/lib/invoice_extractor_arca/pdf/ocr_text_extractor.rb +131 -0
  26. data/lib/invoice_extractor_arca/pdf/renderer.rb +97 -0
  27. data/lib/invoice_extractor_arca/pdf/text_extractor.rb +182 -0
  28. data/lib/invoice_extractor_arca/qr/payload_parser.rb +124 -0
  29. data/lib/invoice_extractor_arca/qr/reader.rb +53 -0
  30. data/lib/invoice_extractor_arca/qr/result_builder.rb +229 -0
  31. data/lib/invoice_extractor_arca/qr_extractor.rb +156 -0
  32. data/lib/invoice_extractor_arca/result.rb +94 -0
  33. data/lib/invoice_extractor_arca/scanners/factory.rb +88 -0
  34. data/lib/invoice_extractor_arca/scanners/qreader_scan.py +102 -0
  35. data/lib/invoice_extractor_arca/scanners/qreader_scanner.rb +131 -0
  36. data/lib/invoice_extractor_arca/scanners/scanner_chain.rb +73 -0
  37. data/lib/invoice_extractor_arca/scanners/zbar_scanner.rb +52 -0
  38. data/lib/invoice_extractor_arca/text/invoice_parser.rb +517 -0
  39. data/lib/invoice_extractor_arca/text/layout_table_parser.rb +218 -0
  40. data/lib/invoice_extractor_arca/text/locale_value_normalizer.rb +53 -0
  41. data/lib/invoice_extractor_arca/text/normalizer.rb +31 -0
  42. data/lib/invoice_extractor_arca/validation/reconciler.rb +53 -0
  43. data/lib/invoice_extractor_arca/version.rb +5 -0
  44. data/lib/invoice_extractor_arca/voucher_validator.rb +169 -0
  45. data/lib/invoice_extractor_arca.rb +117 -0
  46. data/requirements-ocr.txt +2 -0
  47. data/sig/invoice_extractor_arca.rbs +4 -0
  48. metadata +135 -0
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module InvoiceExtractorArca
8
+ module Qr
9
+ class PayloadParser
10
+ def parse(raw_value, image_file:, page_number: nil)
11
+ uri = URI.parse(raw_value)
12
+ p_value = query_param(uri.query, "p")
13
+
14
+ if p_value.nil? || p_value.empty?
15
+ return invalid_candidate(
16
+ raw_value,
17
+ "missing_p_parameter",
18
+ "QR URL does not include a p query parameter",
19
+ image_file: image_file,
20
+ page_number: page_number
21
+ )
22
+ end
23
+
24
+ decoded_json, warning = decode_base64(p_value)
25
+ payload = JSON.parse(decoded_json)
26
+
27
+ unless payload.is_a?(Hash)
28
+ return invalid_candidate(
29
+ raw_value,
30
+ "invalid_payload_json",
31
+ "Decoded QR payload is JSON, but not an object",
32
+ image_file: image_file,
33
+ page_number: page_number
34
+ )
35
+ end
36
+
37
+ {
38
+ valid: true,
39
+ url: raw_value,
40
+ p: p_value,
41
+ decoded_json: decoded_json,
42
+ payload: payload,
43
+ image_file: image_file,
44
+ page_number: page_number,
45
+ errors: [],
46
+ warnings: [warning].compact
47
+ }
48
+ rescue URI::InvalidURIError => e
49
+ invalid_candidate(
50
+ raw_value,
51
+ "invalid_qr_url",
52
+ e.message,
53
+ image_file: image_file,
54
+ page_number: page_number
55
+ )
56
+ rescue ArgumentError => e
57
+ invalid_candidate(
58
+ raw_value,
59
+ "invalid_base64_payload",
60
+ e.message,
61
+ image_file: image_file,
62
+ page_number: page_number
63
+ )
64
+ rescue JSON::ParserError => e
65
+ invalid_candidate(
66
+ raw_value,
67
+ "invalid_json_payload",
68
+ e.message,
69
+ image_file: image_file,
70
+ page_number: page_number
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def query_param(query, name)
77
+ URI.decode_www_form(query.to_s).each do |key, value|
78
+ return value if key == name
79
+ end
80
+
81
+ nil
82
+ end
83
+
84
+ def decode_base64(value)
85
+ [Base64.strict_decode64(value), nil]
86
+ rescue ArgumentError
87
+ [
88
+ Base64.urlsafe_decode64(value),
89
+ {
90
+ code: "urlsafe_base64_payload",
91
+ message: "QR payload used URL-safe Base64 decoding",
92
+ component: "qr"
93
+ }
94
+ ]
95
+ end
96
+
97
+ def invalid_candidate(
98
+ raw_value,
99
+ code,
100
+ message,
101
+ image_file:,
102
+ page_number:
103
+ )
104
+ {
105
+ valid: false,
106
+ url: raw_value,
107
+ p: nil,
108
+ decoded_json: nil,
109
+ payload: nil,
110
+ image_file: image_file,
111
+ page_number: page_number,
112
+ errors: [
113
+ {
114
+ code: code,
115
+ message: message,
116
+ component: "qr"
117
+ }
118
+ ],
119
+ warnings: []
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ module Qr
5
+ class Reader
6
+ def initialize(scanner:, parser:, result_builder:)
7
+ @scanner = scanner
8
+ @parser = parser
9
+ @result_builder = result_builder
10
+ end
11
+
12
+ def read(
13
+ source_file:,
14
+ targets:,
15
+ image_files: targets.map { |target| target[:path] }
16
+ )
17
+ outputs = targets.map { |target| scan_target(target) }
18
+
19
+ candidates = outputs.flat_map do |output|
20
+ output[:values].map do |raw_value|
21
+ @parser.parse(
22
+ raw_value,
23
+ image_file: output[:image_file],
24
+ page_number: output[:page_number]
25
+ )
26
+ end
27
+ end
28
+
29
+ warnings = outputs.flat_map { |output| output[:warnings] }
30
+
31
+ @result_builder.build(
32
+ source_file: source_file,
33
+ image_files: image_files,
34
+ candidates: candidates,
35
+ warnings: warnings
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def scan_target(target)
42
+ result = @scanner.scan(target.fetch(:path))
43
+
44
+ {
45
+ image_file: target.fetch(:path),
46
+ page_number: target[:page_number],
47
+ values: Array(result[:values]),
48
+ warnings: Array(result[:warnings])
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module InvoiceExtractorArca
6
+ module Qr
7
+ class ResultBuilder
8
+ MODES = %i[first_valid all error].freeze
9
+
10
+ def initialize(multiple_qr: :first_valid)
11
+ @multiple_qr = normalize_mode(multiple_qr)
12
+ end
13
+
14
+ def build(
15
+ source_file:,
16
+ image_files:,
17
+ candidates:,
18
+ warnings:
19
+ )
20
+ valid_candidates = candidates.select { |candidate| candidate[:valid] }
21
+ distinct_candidates = distinct(valid_candidates)
22
+
23
+ if valid_candidates.empty?
24
+ return not_found_result(
25
+ source_file: source_file,
26
+ image_files: image_files,
27
+ candidates: candidates,
28
+ warnings: warnings
29
+ )
30
+ end
31
+
32
+ if @multiple_qr == :error && distinct_candidates.length > 1
33
+ return multiple_qr_result(
34
+ source_file: source_file,
35
+ image_files: image_files,
36
+ candidates: candidates,
37
+ valid_candidates: valid_candidates,
38
+ distinct_candidates: distinct_candidates,
39
+ warnings: warnings
40
+ )
41
+ end
42
+
43
+ success_result(
44
+ source_file: source_file,
45
+ image_files: image_files,
46
+ candidates: candidates,
47
+ selected: valid_candidates.first,
48
+ valid_candidates: valid_candidates,
49
+ distinct_candidates: distinct_candidates,
50
+ warnings: warnings
51
+ )
52
+ end
53
+
54
+ def failure(source_file:, code:, message:)
55
+ {
56
+ found: false,
57
+ count: 0,
58
+ valid_count: 0,
59
+ distinct_valid_count: 0,
60
+ duplicate_valid_count: 0,
61
+ raw_values: [],
62
+ selected_index: nil,
63
+ selected_url: nil,
64
+ p: nil,
65
+ decoded_json: nil,
66
+ payload: nil,
67
+ candidates: [],
68
+ source_file: source_file,
69
+ image_file: nil,
70
+ image_files: [],
71
+ errors: [
72
+ {
73
+ code: code,
74
+ message: message,
75
+ component: "qr"
76
+ }
77
+ ],
78
+ warnings: []
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ def normalize_mode(value)
85
+ mode = value.to_sym
86
+ return mode if MODES.include?(mode)
87
+
88
+ raise Errors::UnsupportedFileType,
89
+ "Invalid multiple_qr mode: #{value.inspect}"
90
+ end
91
+
92
+ def not_found_result(
93
+ source_file:,
94
+ image_files:,
95
+ candidates:,
96
+ warnings:
97
+ )
98
+ errors = candidates.flat_map { |candidate| candidate[:errors] }
99
+
100
+ base_result(
101
+ source_file: source_file,
102
+ image_files: image_files,
103
+ candidates: candidates,
104
+ warnings: collected_warnings(candidates, warnings)
105
+ ).merge(
106
+ found: false,
107
+ valid_count: 0,
108
+ distinct_valid_count: 0,
109
+ duplicate_valid_count: 0,
110
+ errors: errors
111
+ )
112
+ end
113
+
114
+ def multiple_qr_result(
115
+ source_file:,
116
+ image_files:,
117
+ candidates:,
118
+ valid_candidates:,
119
+ distinct_candidates:,
120
+ warnings:
121
+ )
122
+ base_result(
123
+ source_file: source_file,
124
+ image_files: image_files,
125
+ candidates: candidates,
126
+ warnings: collected_warnings(candidates, warnings)
127
+ ).merge(
128
+ found: false,
129
+ valid_count: valid_candidates.length,
130
+ distinct_valid_count: distinct_candidates.length,
131
+ duplicate_valid_count:
132
+ valid_candidates.length - distinct_candidates.length,
133
+ errors: [
134
+ {
135
+ code: "multiple_qr_found",
136
+ message:
137
+ "More than one distinct ARCA QR payload was found",
138
+ component: "qr",
139
+ details: {
140
+ valid_count: valid_candidates.length,
141
+ distinct_valid_count: distinct_candidates.length
142
+ }
143
+ }
144
+ ]
145
+ )
146
+ end
147
+
148
+ def success_result(
149
+ source_file:,
150
+ image_files:,
151
+ candidates:,
152
+ selected:,
153
+ valid_candidates:,
154
+ distinct_candidates:,
155
+ warnings:
156
+ )
157
+ base_result(
158
+ source_file: source_file,
159
+ image_files: image_files,
160
+ candidates: candidates,
161
+ warnings: collected_warnings(candidates, warnings)
162
+ ).merge(
163
+ found: true,
164
+ valid_count: valid_candidates.length,
165
+ distinct_valid_count: distinct_candidates.length,
166
+ duplicate_valid_count:
167
+ valid_candidates.length - distinct_candidates.length,
168
+ selected_index: candidates.index(selected),
169
+ selected_url: selected[:url],
170
+ p: selected[:p],
171
+ decoded_json: selected[:decoded_json],
172
+ payload: selected[:payload],
173
+ errors: []
174
+ )
175
+ end
176
+
177
+ def base_result(source_file:, image_files:, candidates:, warnings:)
178
+ {
179
+ found: false,
180
+ count: candidates.length,
181
+ valid_count: 0,
182
+ distinct_valid_count: 0,
183
+ duplicate_valid_count: 0,
184
+ raw_values: candidates.map { |candidate| candidate[:url] },
185
+ selected_index: nil,
186
+ selected_url: nil,
187
+ p: nil,
188
+ decoded_json: nil,
189
+ payload: nil,
190
+ candidates: candidates,
191
+ source_file: source_file,
192
+ image_file: image_files.first,
193
+ image_files: image_files,
194
+ errors: [],
195
+ warnings: warnings
196
+ }
197
+ end
198
+
199
+ def collected_warnings(candidates, scanner_warnings)
200
+ scanner_warnings +
201
+ candidates.flat_map { |candidate| candidate[:warnings] }
202
+ end
203
+
204
+ def distinct(candidates)
205
+ candidates.uniq do |candidate|
206
+ canonical_payload(candidate[:payload])
207
+ end
208
+ end
209
+
210
+ def canonical_payload(payload)
211
+ JSON.generate(deep_sort(payload))
212
+ end
213
+
214
+ def deep_sort(value)
215
+ case value
216
+ when Hash
217
+ value
218
+ .sort_by { |key, _value| key.to_s }
219
+ .to_h
220
+ .transform_values { |child| deep_sort(child) }
221
+ when Array
222
+ value.map { |child| deep_sort(child) }
223
+ else
224
+ value
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "pdf/renderer"
5
+ require_relative "scanners/factory"
6
+ require_relative "qr/payload_parser"
7
+ require_relative "qr/result_builder"
8
+ require_relative "qr/reader"
9
+
10
+ module InvoiceExtractorArca
11
+ class QrExtractor
12
+ IMAGE_EXTENSIONS = %w[
13
+ .png
14
+ .jpg
15
+ .jpeg
16
+ .webp
17
+ .bmp
18
+ .tif
19
+ .tiff
20
+ ].freeze
21
+
22
+ def initialize(
23
+ resolver:,
24
+ runner:,
25
+ renderer: nil,
26
+ converter: nil,
27
+ page_range: nil,
28
+ resolution: 300,
29
+ multiple_qr: :first_valid,
30
+ scanner: nil,
31
+ qr_scanner: :zbar,
32
+ python_command: :python3,
33
+ parser: Qr::PayloadParser.new,
34
+ result_builder: nil,
35
+ reader: nil
36
+ )
37
+ @page_range = page_range
38
+ @resolution = resolution
39
+
40
+ # `converter` remains temporarily supported for compatibility.
41
+ @renderer =
42
+ renderer ||
43
+ converter ||
44
+ Pdf::Renderer.new(
45
+ resolver: resolver,
46
+ runner: runner
47
+ )
48
+
49
+ scanner ||=
50
+ Scanners::Factory.new(
51
+ resolver: resolver,
52
+ runner: runner,
53
+ python_command: python_command
54
+ ).build(qr_scanner)
55
+
56
+ result_builder ||=
57
+ Qr::ResultBuilder.new(multiple_qr: multiple_qr)
58
+
59
+ @result_builder = result_builder
60
+
61
+ @reader =
62
+ reader ||
63
+ Qr::Reader.new(
64
+ scanner: scanner,
65
+ parser: parser,
66
+ result_builder: result_builder
67
+ )
68
+ end
69
+
70
+ def extract(file_path)
71
+ if pdf?(file_path)
72
+ extract_pdf(file_path)
73
+ elsif image?(file_path)
74
+ extract_image(file_path)
75
+ else
76
+ unsupported_file(file_path)
77
+ end
78
+ rescue Errors::UnsupportedFileType => e
79
+ @result_builder.failure(
80
+ source_file: file_path,
81
+ code: "unsupported_file_type",
82
+ message: e.message
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ def extract_pdf(file_path)
89
+ @renderer.render(
90
+ file_path,
91
+ page_range: @page_range,
92
+ resolution: @resolution
93
+ ) do |image_paths|
94
+ paths = Array(image_paths)
95
+ pages = requested_pages(paths.length)
96
+
97
+ targets = paths.zip(pages).map do |path, page_number|
98
+ {
99
+ path: path,
100
+ page_number: page_number
101
+ }
102
+ end
103
+
104
+ @reader.read(
105
+ source_file: file_path,
106
+ targets: targets,
107
+ image_files: paths
108
+ )
109
+ end
110
+ end
111
+
112
+ def extract_image(file_path)
113
+ @reader.read(
114
+ source_file: file_path,
115
+ targets: [
116
+ {
117
+ path: file_path,
118
+ page_number: nil
119
+ }
120
+ ],
121
+ image_files: []
122
+ )
123
+ end
124
+
125
+ def requested_pages(rendered_count)
126
+ pages =
127
+ case @page_range
128
+ when nil
129
+ [1]
130
+ when Integer
131
+ [@page_range]
132
+ when Range
133
+ @page_range.to_a
134
+ when Array
135
+ @page_range
136
+ else
137
+ []
138
+ end
139
+
140
+ pages.first(rendered_count)
141
+ end
142
+
143
+ def unsupported_file(file_path)
144
+ raise Errors::UnsupportedFileType,
145
+ "Unsupported file type: #{File.extname(file_path)}"
146
+ end
147
+
148
+ def pdf?(file_path)
149
+ File.extname(file_path).downcase == ".pdf"
150
+ end
151
+
152
+ def image?(file_path)
153
+ IMAGE_EXTENSIONS.include?(File.extname(file_path).downcase)
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module InvoiceExtractorArca
6
+ class Result
7
+ attr_reader :source,
8
+ :qr,
9
+ :voucher,
10
+ :text,
11
+ :enrichment,
12
+ :validation,
13
+ :errors,
14
+ :metadata,
15
+ :final
16
+
17
+ def initialize(
18
+ source: {},
19
+ qr: {},
20
+ voucher: nil,
21
+ text: {},
22
+ enrichment: {},
23
+ validation: {},
24
+ errors: [],
25
+ metadata: {},
26
+ final: {}
27
+ )
28
+ @source = source || {}
29
+ @qr = qr || {}
30
+ @voucher = voucher
31
+ @text = text || {}
32
+ @enrichment = enrichment || {}
33
+ @validation = validation || {}
34
+ @errors = errors || []
35
+ @metadata = metadata || {}
36
+ @final = final || {}
37
+ end
38
+
39
+ def success?
40
+ errors.none?(&:error?) && !voucher.nil? && qr[:found] == true
41
+ end
42
+
43
+ def failure?
44
+ !success?
45
+ end
46
+
47
+ def final_payload
48
+ {
49
+ qr_payload: voucher&.dig(:original),
50
+ voucher: voucher&.dig(:normalized),
51
+ enrichment: normalized_enrichment
52
+ }
53
+ end
54
+
55
+ def normalized_enrichment
56
+ enrichment.each_with_object({}) do |(name, value), output|
57
+ output[name] =
58
+ if name == :service_rows
59
+ Array(value).map { |row| normalized_service_row(row) }
60
+ elsif value.is_a?(Hash)
61
+ value[:normalized]
62
+ else
63
+ value
64
+ end
65
+ end
66
+ end
67
+
68
+ def normalized_service_row(row)
69
+ row.each_with_object({}) do |(name, value), output|
70
+ output[name] =
71
+ value.is_a?(Hash) ? value[:normalized] : value
72
+ end
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ source: source,
78
+ data: final_payload,
79
+ qr: qr,
80
+ voucher: voucher,
81
+ text: text,
82
+ enrichment: enrichment,
83
+ validation: validation,
84
+ errors: errors.map(&:to_h),
85
+ metadata: metadata,
86
+ final: final
87
+ }
88
+ end
89
+
90
+ def to_json(*args)
91
+ to_h.to_json(*args)
92
+ end
93
+ end
94
+ end