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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module InvoiceExtractorArca
6
+ module Dependencies
7
+ class Resolver
8
+ REQUIRED = %i[
9
+ pdftoppm
10
+ pdftotext
11
+ zbarimg
12
+ ].freeze
13
+
14
+ def initialize(custom_paths: {}, path_env: ENV["PATH"])
15
+ @custom_paths = normalize_keys(custom_paths || {})
16
+ @path_env = path_env.to_s
17
+ end
18
+
19
+ def resolve!(name)
20
+ name = name.to_sym
21
+ custom_path = @custom_paths[name]
22
+
23
+ return validate_custom!(name, custom_path) if custom_path
24
+
25
+ find_on_path(name.to_s) || raise_missing(name)
26
+ end
27
+
28
+ def resolve_all!(*names)
29
+ names = REQUIRED if names.empty?
30
+
31
+ missing = []
32
+ resolved = {}
33
+
34
+ names.each do |name|
35
+ resolved[name.to_sym] = resolve!(name)
36
+ rescue Errors::MissingDependency
37
+ missing << name.to_sym
38
+ end
39
+
40
+ raise_missing_group(missing) unless missing.empty?
41
+
42
+ resolved
43
+ end
44
+
45
+ def check_all(*names)
46
+ names = REQUIRED if names.empty?
47
+
48
+ names.each_with_object({}) do |name, results|
49
+ results[name.to_sym] = {
50
+ available: true,
51
+ path: resolve!(name),
52
+ error: nil
53
+ }
54
+ rescue Errors::MissingDependency => e
55
+ results[name.to_sym] = {
56
+ available: false,
57
+ path: nil,
58
+ error: e.message
59
+ }
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_keys(hash)
66
+ hash.each_with_object({}) do |(key, value), normalized|
67
+ normalized[key.to_sym] = value
68
+ end
69
+ end
70
+
71
+ def validate_custom!(name, path)
72
+ expanded = File.expand_path(path.to_s)
73
+
74
+ return expanded if File.file?(expanded) && File.executable?(expanded)
75
+
76
+ raise Errors::MissingDependency.new(
77
+ "Configured #{name} is not executable: #{expanded}",
78
+ missing: [name]
79
+ )
80
+ end
81
+
82
+ def find_on_path(command)
83
+ @path_env.split(File::PATH_SEPARATOR).each do |directory|
84
+ next if directory.to_s.empty?
85
+
86
+ candidate = File.join(directory, command)
87
+
88
+ if File.file?(candidate) && File.executable?(candidate)
89
+ return File.expand_path(candidate)
90
+ end
91
+ end
92
+
93
+ nil
94
+ end
95
+
96
+ def raise_missing(name)
97
+ raise Errors::MissingDependency.new(
98
+ missing_dependency_message([name]),
99
+ missing: [name]
100
+ )
101
+ end
102
+
103
+ def raise_missing_group(names)
104
+ raise Errors::MissingDependency.new(
105
+ missing_dependency_message(names),
106
+ missing: names
107
+ )
108
+ end
109
+
110
+ def missing_dependency_message(names)
111
+ commands = names.join(", ")
112
+
113
+ "Missing external dependency: #{commands}. " \
114
+ "Install the missing command or configure dependency_paths."
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ class ErrorRecord
5
+ attr_reader :code, :message, :severity, :component, :details
6
+
7
+ def initialize(
8
+ code:,
9
+ message:,
10
+ severity: "error",
11
+ component: nil,
12
+ details: {}
13
+ )
14
+ @code = code.to_s
15
+ @message = message.to_s
16
+ @severity = severity.to_s
17
+ @component = component&.to_s
18
+ @details = details || {}
19
+ end
20
+
21
+ def error?
22
+ severity == "error"
23
+ end
24
+
25
+ def warning?
26
+ severity == "warning"
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ code: code,
32
+ message: message,
33
+ severity: severity,
34
+ component: component,
35
+ details: details
36
+ }.compact
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module InvoiceExtractorArca
6
+ module Errors
7
+ class MissingDependency < Error
8
+ attr_reader :missing
9
+
10
+ def initialize(message = nil, missing: [])
11
+ @missing = Array(missing)
12
+ super(message || "Missing external dependency: #{missing.join(", ")}")
13
+ end
14
+ end
15
+
16
+ class UnsupportedFileType < Error
17
+ end
18
+
19
+ class UnreadableFile < Error
20
+ end
21
+
22
+ class QrNotFound < Error
23
+ end
24
+
25
+ class InvalidVoucherSchema < Error
26
+ end
27
+
28
+ class MultipleQrFound < Error
29
+ end
30
+
31
+ class PdfTextExtractionFailure < Error
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "result"
5
+ require_relative "errors"
6
+ require_relative "error_record"
7
+ require_relative "command_runner"
8
+ require_relative "dependencies/resolver"
9
+ require_relative "qr_extractor"
10
+ require_relative "voucher_validator"
11
+ require_relative "pdf/text_extractor"
12
+ require_relative "text/normalizer"
13
+ require_relative "ocr/rapidocr_text_extractor"
14
+ require_relative "final_payload_builder"
15
+
16
+ module InvoiceExtractorArca
17
+ class Extractor
18
+ def initialize(
19
+ strict: false,
20
+ dependency_paths: {},
21
+ page_range: nil,
22
+ resolution: 300,
23
+ multiple_qr: :first_valid,
24
+ qr_scanner: :zbar_then_qreader,
25
+ python_command: :python3,
26
+ resolver: nil,
27
+ runner: CommandRunner.new,
28
+ qr_extractor: nil,
29
+ voucher_validator: VoucherValidator.new,
30
+ enrichment: true,
31
+ text_extractor: nil,
32
+ invoice_parser: nil,
33
+ reconciler: nil,
34
+ image_text_extractor: nil,
35
+ pdf_ocr_text_extractor: nil,
36
+ final_payload_builder: FinalPayloadBuilder.new
37
+ )
38
+ @configuration = Configuration.new(
39
+ strict: strict,
40
+ dependency_paths: dependency_paths,
41
+ page_range: page_range,
42
+ resolution: resolution,
43
+ multiple_qr: multiple_qr,
44
+ qr_scanner: qr_scanner,
45
+ python_command: python_command,
46
+ enrichment: enrichment
47
+ )
48
+
49
+ @resolver =
50
+ resolver ||
51
+ Dependencies::Resolver.new(
52
+ custom_paths: @configuration.dependency_paths
53
+ )
54
+
55
+ @runner = runner
56
+ @voucher_validator = voucher_validator
57
+ @text_extractor =
58
+ text_extractor ||
59
+ Pdf::TextExtractor.new(
60
+ resolver: @resolver,
61
+ runner: @runner
62
+ )
63
+
64
+ @invoice_parser =
65
+ invoice_parser ||
66
+ Text::InvoiceParser.new
67
+
68
+ @reconciler =
69
+ reconciler ||
70
+ Validation::Reconciler.new
71
+
72
+ @qr_extractor =
73
+ qr_extractor ||
74
+ QrExtractor.new(
75
+ resolver: @resolver,
76
+ runner: @runner,
77
+ page_range: effective_page_range,
78
+ resolution: @configuration.resolution,
79
+ multiple_qr: @configuration.multiple_qr,
80
+ qr_scanner: @configuration.qr_scanner,
81
+ python_command: @configuration.python_command
82
+ )
83
+
84
+ @image_text_extractor =
85
+ image_text_extractor ||
86
+ Ocr::RapidocrTextExtractor.new(
87
+ resolver: @resolver,
88
+ runner: @runner,
89
+ python_command: @configuration.python_command
90
+ )
91
+
92
+ @pdf_ocr_text_extractor =
93
+ pdf_ocr_text_extractor ||
94
+ Pdf::OcrTextExtractor.new(
95
+ renderer: Pdf::Renderer.new(
96
+ resolver: @resolver,
97
+ runner: @runner
98
+ ),
99
+ image_text_extractor: @image_text_extractor,
100
+ resolution: @configuration.resolution
101
+ )
102
+
103
+ @final_payload_builder = final_payload_builder
104
+ end
105
+
106
+ def extract(file_path)
107
+ validate_file!(file_path)
108
+
109
+ qr_result = @qr_extractor.extract(file_path)
110
+ voucher, errors = build_voucher_and_errors(qr_result)
111
+
112
+ return gated_failure(file_path, qr_result, voucher, errors) if errors.any?(&:error?)
113
+
114
+ text = extract_text(file_path)
115
+ unless text[:extracted]
116
+ diagnostics = text_warning_records(text)
117
+
118
+ return Result.new(
119
+ source: {path: file_path},
120
+ qr: qr_result,
121
+ voucher: voucher,
122
+ text: text,
123
+ enrichment: {},
124
+ validation: {
125
+ status: diagnostics.empty? ? "ok" : "warning",
126
+ comparisons: []
127
+ },
128
+ errors: diagnostics,
129
+ metadata: {}
130
+ )
131
+ end
132
+ parsed = @invoice_parser.parse(
133
+ raw: text[:raw],
134
+ normalized: text[:normalized],
135
+ voucher: voucher[:normalized]
136
+ )
137
+
138
+ validation = @reconciler.call(
139
+ voucher: voucher[:normalized],
140
+ enrichment: parsed[:enrichment],
141
+ parsing_warnings: parsed[:diagnostics]
142
+ )
143
+ Result.new(
144
+ source: {path: file_path},
145
+ qr: qr_result,
146
+ voucher: voucher,
147
+ text: text,
148
+ enrichment: parsed[:enrichment],
149
+ validation: validation,
150
+ errors: parsed[:diagnostics],
151
+ metadata: {},
152
+ final: @final_payload_builder.build(
153
+ qr_result: qr_result,
154
+ voucher: voucher,
155
+ enrichment: parsed[:enrichment]
156
+ )
157
+ )
158
+ rescue Error => e
159
+ raise if @configuration.strict?
160
+
161
+ failure_result(file_path, e)
162
+ end
163
+
164
+ def gated_failure(file_path, qr_result, voucher, errors)
165
+ raise_strict_error!(errors.first) if @configuration.strict?
166
+
167
+ Result.new(
168
+ source: {path: file_path},
169
+ qr: qr_result,
170
+ voucher: voucher,
171
+ text: {},
172
+ enrichment: {},
173
+ validation: {status: "error", comparisons: []},
174
+ errors: errors,
175
+ metadata: {}
176
+ )
177
+ end
178
+
179
+ private
180
+
181
+ def validate_file!(file_path)
182
+ unless File.file?(file_path) && File.readable?(file_path)
183
+ raise Errors::UnreadableFile,
184
+ "File not found or unreadable: #{file_path}"
185
+ end
186
+ end
187
+
188
+ def build_voucher_and_errors(qr_result)
189
+ unless qr_result[:found]
190
+ return [
191
+ nil,
192
+ qr_error_records(qr_result)
193
+ ]
194
+ end
195
+
196
+ original = qr_result[:payload]
197
+ normalized = @voucher_validator.validate_and_normalize(original)
198
+
199
+ [
200
+ {
201
+ original: original,
202
+ normalized: normalized
203
+ },
204
+ []
205
+ ]
206
+ rescue Errors::InvalidVoucherSchema => e
207
+ [
208
+ {
209
+ original: defined?(original) ? original : nil,
210
+ normalized: {}
211
+ },
212
+ [
213
+ ErrorRecord.new(
214
+ code: "invalid_voucher_schema",
215
+ message: e.message,
216
+ component: "voucher"
217
+ )
218
+ ]
219
+ ]
220
+ end
221
+
222
+ def failure_result(file_path, error)
223
+ Result.new(
224
+ source: {
225
+ path: file_path
226
+ },
227
+ qr: {},
228
+ voucher: nil,
229
+ text: {},
230
+ enrichment: {},
231
+ validation: {},
232
+ errors: [
233
+ error_record_from_exception(error)
234
+ ],
235
+ metadata: {}
236
+ )
237
+ end
238
+
239
+ def qr_error_records(qr_result)
240
+ qr_errors = Array(qr_result[:errors])
241
+
242
+ return [default_qr_not_found_error] if qr_errors.empty?
243
+
244
+ qr_errors.map do |error|
245
+ ErrorRecord.new(
246
+ code: error[:code],
247
+ message: error[:message],
248
+ component: error[:component],
249
+ details: error[:details] || {}
250
+ )
251
+ end
252
+ end
253
+
254
+ def default_qr_not_found_error
255
+ ErrorRecord.new(
256
+ code: "qr_not_found",
257
+ message: "No valid ARCA QR payload was found",
258
+ component: "qr"
259
+ )
260
+ end
261
+
262
+ def error_record_from_exception(error)
263
+ case error
264
+ when Errors::UnreadableFile
265
+ ErrorRecord.new(
266
+ code: "unreadable_file",
267
+ message: error.message,
268
+ component: "source"
269
+ )
270
+ when Errors::MissingDependency
271
+ ErrorRecord.new(
272
+ code: "missing_dependency",
273
+ message: error.message,
274
+ component: "dependencies",
275
+ details: {
276
+ missing: error.missing
277
+ }
278
+ )
279
+ when Errors::UnsupportedFileType
280
+ ErrorRecord.new(
281
+ code: "unsupported_file_type",
282
+ message: error.message,
283
+ component: "source"
284
+ )
285
+ else
286
+ ErrorRecord.new(
287
+ code: "extraction_error",
288
+ message: error.message,
289
+ component: "extractor"
290
+ )
291
+ end
292
+ end
293
+
294
+ def raise_strict_error!(error_record)
295
+ case error_record.code
296
+ when "qr_not_found"
297
+ raise Errors::QrNotFound, error_record.message
298
+ when "multiple_qr_found"
299
+ raise Errors::MultipleQrFound, error_record.message
300
+ when "invalid_voucher_schema"
301
+ raise Errors::InvalidVoucherSchema, error_record.message
302
+ else
303
+ raise Error, error_record.message
304
+ end
305
+ end
306
+
307
+ def extract_text(file_path)
308
+ return disabled_text_result unless @configuration.enrichment?
309
+
310
+ if pdf?(file_path)
311
+ pdf_text = @text_extractor.extract(
312
+ file_path,
313
+ page_range: effective_page_range
314
+ )
315
+
316
+ return pdf_text if pdf_text[:extracted]
317
+
318
+ empty_text = Array(pdf_text[:warnings]).any? do |warning|
319
+ warning[:code] == "pdf_text_empty"
320
+ end
321
+
322
+ return pdf_text unless empty_text
323
+
324
+ @pdf_ocr_text_extractor.extract(
325
+ file_path,
326
+ page_range: effective_page_range
327
+ )
328
+ else
329
+ @image_text_extractor.extract(file_path)
330
+ end
331
+ end
332
+
333
+ def disabled_text_result
334
+ {
335
+ extracted: false,
336
+ source: nil,
337
+ raw: "",
338
+ normalized: "",
339
+ pages: nil,
340
+ warnings: [
341
+ {
342
+ code: "enrichment_disabled",
343
+ message: "Text extraction is disabled",
344
+ component: "text"
345
+ }
346
+ ]
347
+ }
348
+ end
349
+
350
+ def not_applicable_text_result
351
+ {
352
+ extracted: false,
353
+ source: nil,
354
+ raw: "",
355
+ normalized: "",
356
+ pages: nil,
357
+ warnings: []
358
+ }
359
+ end
360
+
361
+ def pdf?(file_path)
362
+ File.extname(file_path).downcase == ".pdf"
363
+ end
364
+
365
+ def text_warning_records(text)
366
+ Array(text[:warnings]).map do |warning|
367
+ ErrorRecord.new(
368
+ code: warning[:code],
369
+ message: warning[:message],
370
+ severity: "warning",
371
+ component: warning[:component] || "text",
372
+ details: warning[:details] || {}
373
+ )
374
+ end
375
+ end
376
+
377
+ def effective_page_range
378
+ @configuration.page_range || 1
379
+ end
380
+ end
381
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ class FinalPayloadBuilder
5
+ def build(qr_result:, voucher:, enrichment:)
6
+ {
7
+ qr_payload: qr_result[:payload] || {},
8
+ normalized: normalized_fields(voucher, enrichment),
9
+ enrichment: serialize_enrichment(enrichment)
10
+ }
11
+ end
12
+
13
+ private
14
+
15
+ def normalized_fields(voucher, enrichment)
16
+ qr_fields = voucher&.dig(:normalized) || {}
17
+
18
+ enrichment_fields =
19
+ enrichment.each_with_object({}) do |(name, field), result|
20
+ next if name == :service_rows
21
+ next unless field.is_a?(Hash)
22
+
23
+ result[name] = field[:normalized]
24
+ end
25
+
26
+ # QR values are authoritative when names overlap.
27
+ enrichment_fields.merge(qr_fields)
28
+ .merge(
29
+ service_rows: normalized_service_rows(
30
+ enrichment[:service_rows]
31
+ )
32
+ )
33
+ end
34
+
35
+ def normalized_service_rows(rows)
36
+ Array(rows).map do |row|
37
+ row.each_with_object({}) do |(name, field), result|
38
+ result[name] =
39
+ if field.is_a?(Hash)
40
+ field[:normalized]
41
+ else
42
+ field
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def serialize_enrichment(enrichment)
49
+ enrichment.each_with_object({}) do |(name, value), result|
50
+ result[name] =
51
+ if name == :service_rows
52
+ Array(value)
53
+ elsif value.is_a?(Hash)
54
+ {
55
+ raw: value[:raw],
56
+ normalized: value[:normalized],
57
+ source: value[:source],
58
+ method: value[:method],
59
+ confidence: value[:confidence]
60
+ }.compact
61
+ else
62
+ value
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end