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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "zbar_scanner"
4
+ require_relative "qreader_scanner"
5
+ require_relative "scanner_chain"
6
+
7
+ module InvoiceExtractorArca
8
+ module Scanners
9
+ class Factory
10
+ MODES = %i[
11
+ zbar
12
+ qreader
13
+ zbar_then_qreader
14
+ qreader_then_zbar
15
+ all
16
+ ].freeze
17
+
18
+ def initialize(
19
+ resolver:,
20
+ runner:,
21
+ python_command: :python3
22
+ )
23
+ @resolver = resolver
24
+ @runner = runner
25
+ @python_command = python_command
26
+ end
27
+
28
+ def build(mode)
29
+ normalized = normalize_mode!(mode)
30
+
31
+ case normalized
32
+ when :zbar
33
+ zbar_scanner
34
+ when :qreader
35
+ qreader_scanner
36
+ when :zbar_then_qreader
37
+ ScannerChain.new(
38
+ scanners: [
39
+ zbar_scanner,
40
+ qreader_scanner
41
+ ],
42
+ mode: :first_success
43
+ )
44
+ when :qreader_then_zbar
45
+ ScannerChain.new(
46
+ scanners: [
47
+ qreader_scanner,
48
+ zbar_scanner
49
+ ],
50
+ mode: :first_success
51
+ )
52
+ when :all
53
+ ScannerChain.new(
54
+ scanners: [
55
+ zbar_scanner,
56
+ qreader_scanner
57
+ ],
58
+ mode: :all
59
+ )
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_mode!(mode)
66
+ normalized = mode.to_sym
67
+ return normalized if MODES.include?(normalized)
68
+
69
+ raise ArgumentError, "Invalid qr_scanner: #{mode.inspect}"
70
+ end
71
+
72
+ def zbar_scanner
73
+ ZbarScanner.new(
74
+ resolver: @resolver,
75
+ runner: @runner
76
+ )
77
+ end
78
+
79
+ def qreader_scanner
80
+ QreaderScanner.new(
81
+ resolver: @resolver,
82
+ runner: @runner,
83
+ python_command: @python_command
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import contextlib
4
+ import json
5
+ import sys
6
+
7
+ ORIGINAL_STDOUT = sys.stdout
8
+
9
+
10
+ def emit(payload):
11
+ print(json.dumps(payload), file=ORIGINAL_STDOUT)
12
+
13
+
14
+ def warning_payload(code, message):
15
+ return {
16
+ "values": [],
17
+ "warnings": [
18
+ {
19
+ "code": code,
20
+ "message": str(message),
21
+ }
22
+ ],
23
+ }
24
+
25
+
26
+ def main():
27
+ if len(sys.argv) != 2:
28
+ emit(
29
+ warning_payload(
30
+ "qreader_invalid_arguments",
31
+ "Expected exactly one image path argument",
32
+ )
33
+ )
34
+ return
35
+
36
+ image_path = sys.argv[1]
37
+
38
+ try:
39
+ # Keep stdout JSON-only for the Ruby bridge.
40
+ with contextlib.redirect_stdout(sys.stderr):
41
+ import cv2
42
+ from qreader import QReader
43
+ except Exception as exc:
44
+ emit(warning_payload("qreader_import_failed", exc))
45
+ return
46
+
47
+ try:
48
+ image_bgr = cv2.imread(image_path)
49
+
50
+ if image_bgr is None:
51
+ emit(
52
+ warning_payload(
53
+ "qreader_image_read_failed",
54
+ f"Could not read image: {image_path}",
55
+ )
56
+ )
57
+ return
58
+
59
+ image = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
60
+
61
+ with contextlib.redirect_stdout(sys.stderr):
62
+ qreader = QReader()
63
+ decoded_values = qreader.detect_and_decode(image=image)
64
+
65
+ decoded_values = decoded_values or []
66
+
67
+ values = [
68
+ value.strip()
69
+ for value in decoded_values
70
+ if isinstance(value, str) and value.strip()
71
+ ]
72
+
73
+ none_count = sum(
74
+ 1 for value in decoded_values if value is None
75
+ )
76
+
77
+ warnings = []
78
+
79
+ if none_count > 0:
80
+ noun = "region" if none_count == 1 else "regions"
81
+ warnings.append(
82
+ {
83
+ "code": "qreader_detected_undecoded",
84
+ "message": (
85
+ f"QReader detected {none_count} additional "
86
+ f"QR-like {noun} that could not be decoded"
87
+ ),
88
+ }
89
+ )
90
+
91
+ emit(
92
+ {
93
+ "values": values,
94
+ "warnings": warnings,
95
+ }
96
+ )
97
+ except Exception as exc:
98
+ emit(warning_payload("qreader_failed", exc))
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../errors"
5
+
6
+ module InvoiceExtractorArca
7
+ module Scanners
8
+ class QreaderScanner
9
+ attr_reader :name
10
+
11
+ DEFAULT_HELPER_PATH =
12
+ File.expand_path("qreader_scan.py", __dir__)
13
+
14
+ def initialize(
15
+ resolver:,
16
+ runner:,
17
+ python_command: :python3,
18
+ helper_path: DEFAULT_HELPER_PATH
19
+ )
20
+ @resolver = resolver
21
+ @runner = runner
22
+ @python_command = python_command
23
+ @helper_path = helper_path
24
+ @name = :qreader
25
+ end
26
+
27
+ def scan(image_path)
28
+ python = @resolver.resolve!(@python_command)
29
+
30
+ result =
31
+ @runner.run(
32
+ python,
33
+ @helper_path,
34
+ image_path
35
+ )
36
+
37
+ parsed = parse_json(result[:stdout])
38
+
39
+ warnings = normalize_warnings(parsed["warnings"], image_path)
40
+
41
+ unless result[:success]
42
+ warnings << {
43
+ code: "qreader_process_failed",
44
+ message: result[:stderr].to_s.strip,
45
+ component: "qr",
46
+ details: {
47
+ scanner: name,
48
+ image_file: image_path
49
+ }
50
+ }
51
+ end
52
+
53
+ {
54
+ values: normalize_values(parsed["values"]),
55
+ warnings: warnings
56
+ }
57
+ rescue Errors::MissingDependency => e
58
+ {
59
+ values: [],
60
+ warnings: [
61
+ {
62
+ code: "qreader_missing_dependency",
63
+ message: e.message,
64
+ component: "qr",
65
+ details: {
66
+ scanner: name,
67
+ image_file: image_path,
68
+ missing: e.missing
69
+ }
70
+ }
71
+ ]
72
+ }
73
+ rescue JSON::ParserError => e
74
+ {
75
+ values: [],
76
+ warnings: [
77
+ {
78
+ code: "qreader_invalid_output",
79
+ message: e.message,
80
+ component: "qr",
81
+ details: {
82
+ scanner: name,
83
+ image_file: image_path
84
+ }
85
+ }
86
+ ]
87
+ }
88
+ end
89
+
90
+ private
91
+
92
+ def parse_json(stdout)
93
+ text = stdout.to_s.strip
94
+
95
+ JSON.parse(text)
96
+ rescue JSON::ParserError
97
+ json_line =
98
+ text
99
+ .lines
100
+ .map(&:strip)
101
+ .reverse
102
+ .find { |line| line.start_with?("{") && line.end_with?("}") }
103
+
104
+ raise unless json_line
105
+
106
+ JSON.parse(json_line)
107
+ end
108
+
109
+ def normalize_values(values)
110
+ Array(values)
111
+ .map(&:to_s)
112
+ .map(&:strip)
113
+ .reject(&:empty?)
114
+ end
115
+
116
+ def normalize_warnings(warnings, image_path)
117
+ Array(warnings).map do |warning|
118
+ {
119
+ code: warning.fetch("code", "qreader_warning"),
120
+ message: warning.fetch("message", ""),
121
+ component: "qr",
122
+ details: {
123
+ scanner: name,
124
+ image_file: image_path
125
+ }
126
+ }
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ module Scanners
5
+ class ScannerChain
6
+ MODES = %i[
7
+ first_success
8
+ all
9
+ ].freeze
10
+
11
+ def initialize(scanners:, mode: :first_success)
12
+ @scanners = Array(scanners)
13
+ @mode = normalize_mode!(mode)
14
+ end
15
+
16
+ def scan(image_path)
17
+ case @mode
18
+ when :first_success
19
+ scan_until_values_found(image_path)
20
+ when :all
21
+ scan_all(image_path)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def normalize_mode!(mode)
28
+ normalized = mode.to_sym
29
+ return normalized if MODES.include?(normalized)
30
+
31
+ raise ArgumentError, "Invalid scanner chain mode: #{mode.inspect}"
32
+ end
33
+
34
+ def scan_until_values_found(image_path)
35
+ all_warnings = []
36
+
37
+ @scanners.each do |scanner|
38
+ result = scanner.scan(image_path)
39
+ all_warnings.concat(Array(result[:warnings]))
40
+
41
+ values = Array(result[:values])
42
+ unless values.empty?
43
+ return {
44
+ values: values,
45
+ warnings: all_warnings
46
+ }
47
+ end
48
+ end
49
+
50
+ {
51
+ values: [],
52
+ warnings: all_warnings
53
+ }
54
+ end
55
+
56
+ def scan_all(image_path)
57
+ values = []
58
+ warnings = []
59
+
60
+ @scanners.each do |scanner|
61
+ result = scanner.scan(image_path)
62
+ values.concat(Array(result[:values]))
63
+ warnings.concat(Array(result[:warnings]))
64
+ end
65
+
66
+ {
67
+ values: values.uniq,
68
+ warnings: warnings
69
+ }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ module Scanners
5
+ class ZbarScanner
6
+ attr_reader :name
7
+
8
+ def initialize(resolver:, runner:)
9
+ @resolver = resolver
10
+ @runner = runner
11
+ @name = :zbar
12
+ end
13
+
14
+ def scan(image_path)
15
+ zbarimg = @resolver.resolve!(:zbarimg)
16
+
17
+ result =
18
+ @runner.run(
19
+ zbarimg,
20
+ "--raw",
21
+ image_path
22
+ )
23
+
24
+ values =
25
+ result[:stdout]
26
+ .to_s
27
+ .lines
28
+ .map(&:strip)
29
+ .reject(&:empty?)
30
+
31
+ warnings = []
32
+
33
+ unless result[:success]
34
+ warnings << {
35
+ code: "zbarimg_failed",
36
+ message: result[:stderr].to_s.strip,
37
+ component: "qr",
38
+ details: {
39
+ scanner: name,
40
+ image_file: image_path
41
+ }
42
+ }
43
+ end
44
+
45
+ {
46
+ values: values,
47
+ warnings: warnings
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end