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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE.txt +21 -0
- data/README.md +631 -0
- data/exe/arca-invoice-extract +6 -0
- data/lib/invoice_extractor_arca/catalogs/copy_types.json +5 -0
- data/lib/invoice_extractor_arca/catalogs/currencies.json +65 -0
- data/lib/invoice_extractor_arca/catalogs/document_types.json +40 -0
- data/lib/invoice_extractor_arca/catalogs/iva_types.json +13 -0
- data/lib/invoice_extractor_arca/catalogs/payment_methods.json +10 -0
- data/lib/invoice_extractor_arca/catalogs/units_measure.json +47 -0
- data/lib/invoice_extractor_arca/catalogs/voucher_types.json +99 -0
- data/lib/invoice_extractor_arca/catalogs.rb +34 -0
- data/lib/invoice_extractor_arca/cli.rb +247 -0
- data/lib/invoice_extractor_arca/command_runner.rb +18 -0
- data/lib/invoice_extractor_arca/configuration.rb +42 -0
- data/lib/invoice_extractor_arca/dependencies/resolver.rb +118 -0
- data/lib/invoice_extractor_arca/error.rb +6 -0
- data/lib/invoice_extractor_arca/error_record.rb +39 -0
- data/lib/invoice_extractor_arca/errors.rb +34 -0
- data/lib/invoice_extractor_arca/extractor.rb +381 -0
- data/lib/invoice_extractor_arca/final_payload_builder.rb +67 -0
- data/lib/invoice_extractor_arca/ocr/rapidocr_extract.py +198 -0
- data/lib/invoice_extractor_arca/ocr/rapidocr_text_extractor.rb +117 -0
- data/lib/invoice_extractor_arca/pdf/ocr_text_extractor.rb +131 -0
- data/lib/invoice_extractor_arca/pdf/renderer.rb +97 -0
- data/lib/invoice_extractor_arca/pdf/text_extractor.rb +182 -0
- data/lib/invoice_extractor_arca/qr/payload_parser.rb +124 -0
- data/lib/invoice_extractor_arca/qr/reader.rb +53 -0
- data/lib/invoice_extractor_arca/qr/result_builder.rb +229 -0
- data/lib/invoice_extractor_arca/qr_extractor.rb +156 -0
- data/lib/invoice_extractor_arca/result.rb +94 -0
- data/lib/invoice_extractor_arca/scanners/factory.rb +88 -0
- data/lib/invoice_extractor_arca/scanners/qreader_scan.py +102 -0
- data/lib/invoice_extractor_arca/scanners/qreader_scanner.rb +131 -0
- data/lib/invoice_extractor_arca/scanners/scanner_chain.rb +73 -0
- data/lib/invoice_extractor_arca/scanners/zbar_scanner.rb +52 -0
- data/lib/invoice_extractor_arca/text/invoice_parser.rb +517 -0
- data/lib/invoice_extractor_arca/text/layout_table_parser.rb +218 -0
- data/lib/invoice_extractor_arca/text/locale_value_normalizer.rb +53 -0
- data/lib/invoice_extractor_arca/text/normalizer.rb +31 -0
- data/lib/invoice_extractor_arca/validation/reconciler.rb +53 -0
- data/lib/invoice_extractor_arca/version.rb +5 -0
- data/lib/invoice_extractor_arca/voucher_validator.rb +169 -0
- data/lib/invoice_extractor_arca.rb +117 -0
- data/requirements-ocr.txt +2 -0
- data/sig/invoice_extractor_arca.rbs +4 -0
- 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
|