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,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
|