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,198 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import contextlib
4
+ import json
5
+ import sys
6
+ from importlib.metadata import version
7
+
8
+ ORIGINAL_STDOUT = sys.stdout
9
+
10
+
11
+ def emit(payload):
12
+ print(
13
+ json.dumps(payload, ensure_ascii=False),
14
+ file=ORIGINAL_STDOUT,
15
+ )
16
+
17
+
18
+ def failure(code, message):
19
+ return {
20
+ "success": False,
21
+ "engine": "rapidocr",
22
+ "version": version("rapidocr"),
23
+ "lines": [],
24
+ "raw_text": "",
25
+ "warnings": [
26
+ {
27
+ "code": code,
28
+ "message": str(message),
29
+ }
30
+ ],
31
+ }
32
+
33
+
34
+ def box_metrics(box):
35
+ xs = [point[0] for point in box]
36
+ ys = [point[1] for point in box]
37
+
38
+ return {
39
+ "left": min(xs),
40
+ "top": min(ys),
41
+ "right": max(xs),
42
+ "bottom": max(ys),
43
+ "center_y": (min(ys) + max(ys)) / 2,
44
+ "height": max(ys) - min(ys),
45
+ }
46
+
47
+
48
+ def build_visual_lines(items):
49
+ positioned = []
50
+
51
+ for item in items:
52
+ if not item["box"]:
53
+ continue
54
+
55
+ metrics = box_metrics(item["box"])
56
+ positioned.append({
57
+ **item,
58
+ **metrics,
59
+ })
60
+
61
+ positioned.sort(
62
+ key=lambda item: (item["center_y"], item["left"])
63
+ )
64
+
65
+ groups = []
66
+
67
+ for item in positioned:
68
+ matching_group = None
69
+
70
+ for group in reversed(groups):
71
+ tolerance = max(
72
+ 10.0,
73
+ group["average_height"] * 0.55,
74
+ item["height"] * 0.55,
75
+ )
76
+
77
+ if abs(item["center_y"] - group["center_y"]) <= tolerance:
78
+ matching_group = group
79
+ break
80
+
81
+ if item["center_y"] - group["center_y"] > tolerance * 2:
82
+ break
83
+
84
+ if matching_group is None:
85
+ groups.append({
86
+ "center_y": item["center_y"],
87
+ "average_height": item["height"],
88
+ "items": [item],
89
+ })
90
+ continue
91
+
92
+ matching_group["items"].append(item)
93
+
94
+ count = len(matching_group["items"])
95
+ matching_group["center_y"] = (
96
+ sum(child["center_y"] for child in matching_group["items"])
97
+ / count
98
+ )
99
+ matching_group["average_height"] = (
100
+ sum(child["height"] for child in matching_group["items"])
101
+ / count
102
+ )
103
+
104
+ groups.sort(key=lambda group: group["center_y"])
105
+
106
+ visual_lines = []
107
+
108
+ for group in groups:
109
+ children = sorted(
110
+ group["items"],
111
+ key=lambda item: item["left"],
112
+ )
113
+
114
+ visual_lines.append({
115
+ "text": " ".join(
116
+ child["text"].strip()
117
+ for child in children
118
+ if child["text"].strip()
119
+ ),
120
+ "items": [
121
+ {
122
+ "text": child["text"],
123
+ "score": child["score"],
124
+ "box": child["box"],
125
+ }
126
+ for child in children
127
+ ],
128
+ })
129
+
130
+ return visual_lines
131
+
132
+
133
+ def main():
134
+ if len(sys.argv) != 2:
135
+ emit(failure(
136
+ "rapidocr_invalid_arguments",
137
+ "Expected exactly one image path",
138
+ ))
139
+ return 2
140
+
141
+ image_path = sys.argv[1]
142
+
143
+ try:
144
+ with contextlib.redirect_stdout(sys.stderr):
145
+ from rapidocr import RapidOCR
146
+
147
+ engine = RapidOCR()
148
+ result = engine(image_path)
149
+
150
+ except Exception as exc:
151
+ emit(failure("rapidocr_failed", exc))
152
+ return 1
153
+
154
+ texts = list(result.txts or [])
155
+ scores = list(result.scores or [])
156
+ boxes = (
157
+ result.boxes.tolist()
158
+ if result.boxes is not None
159
+ else []
160
+ )
161
+
162
+ items = []
163
+
164
+ for index, text in enumerate(texts):
165
+ items.append({
166
+ "text": text,
167
+ "score": (
168
+ float(scores[index])
169
+ if index < len(scores)
170
+ else None
171
+ ),
172
+ "box": (
173
+ boxes[index]
174
+ if index < len(boxes)
175
+ else None
176
+ ),
177
+ })
178
+
179
+ visual_lines = build_visual_lines(items)
180
+
181
+ emit({
182
+ "success": True,
183
+ "engine": "rapidocr",
184
+ "version": version("rapidocr"),
185
+ "lines": visual_lines,
186
+ "raw_text": "\n".join(
187
+ line["text"]
188
+ for line in visual_lines
189
+ if line["text"]
190
+ ),
191
+ "warnings": [],
192
+ })
193
+
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ raise SystemExit(main())
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../text/normalizer"
5
+
6
+ module InvoiceExtractorArca
7
+ module Ocr
8
+ class RapidocrTextExtractor
9
+ DEFAULT_HELPER_PATH =
10
+ File.expand_path("rapidocr_extract.py", __dir__)
11
+
12
+ def initialize(
13
+ resolver:,
14
+ runner:,
15
+ python_command: :python3,
16
+ helper_path: DEFAULT_HELPER_PATH,
17
+ normalizer: Text::Normalizer.new
18
+ )
19
+ @resolver = resolver
20
+ @runner = runner
21
+ @python_command = python_command
22
+ @helper_path = helper_path
23
+ @normalizer = normalizer
24
+ end
25
+
26
+ def extract(image_path)
27
+ python = @resolver.resolve!(@python_command)
28
+
29
+ process = @runner.run(
30
+ python,
31
+ @helper_path,
32
+ image_path.to_s
33
+ )
34
+
35
+ payload = parse_json(process[:stdout])
36
+
37
+ unless process[:success] && payload["success"]
38
+ return failure_result(payload, process[:stderr])
39
+ end
40
+
41
+ raw = payload.fetch("raw_text", "")
42
+ normalized = @normalizer.normalize(raw)
43
+
44
+ {
45
+ extracted: !normalized.empty?,
46
+ source: rapidocr_source(payload),
47
+ raw: raw,
48
+ normalized: normalized,
49
+ pages: nil,
50
+ lines: Array(payload["lines"]),
51
+ warnings: normalized.empty? ? [empty_warning] : []
52
+ }
53
+ rescue Errors::MissingDependency => e
54
+ failure_result(
55
+ {
56
+ "warnings" => [{
57
+ "code" => "rapidocr_missing_dependency",
58
+ "message" => e.message
59
+ }]
60
+ },
61
+ ""
62
+ )
63
+ rescue JSON::ParserError => e
64
+ failure_result(
65
+ {
66
+ "warnings" => [{
67
+ "code" => "rapidocr_invalid_output",
68
+ "message" => e.message
69
+ }]
70
+ },
71
+ ""
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def parse_json(stdout)
78
+ JSON.parse(stdout.to_s.strip)
79
+ end
80
+
81
+ def empty_warning
82
+ {
83
+ code: "image_ocr_empty",
84
+ message: "RapidOCR returned no recognized text",
85
+ component: "text"
86
+ }
87
+ end
88
+
89
+ def failure_result(payload, stderr)
90
+ warnings = Array(payload["warnings"]).map do |warning|
91
+ {
92
+ code: warning.fetch("code", "rapidocr_failed"),
93
+ message: warning.fetch("message", stderr.to_s.strip),
94
+ component: "text"
95
+ }
96
+ end
97
+
98
+ {
99
+ extracted: false,
100
+ source: "rapidocr-3.9.2",
101
+ raw: "",
102
+ normalized: "",
103
+ pages: nil,
104
+ lines: [],
105
+ warnings: warnings
106
+ }
107
+ end
108
+
109
+ def rapidocr_source(payload)
110
+ engine = payload.fetch("engine", "rapidocr")
111
+ version = payload["version"]
112
+
113
+ version ? "#{engine}-#{version}" : engine
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InvoiceExtractorArca
4
+ module Pdf
5
+ class OcrTextExtractor
6
+ def initialize(renderer:, image_text_extractor:, resolution: 300)
7
+ @renderer = renderer
8
+ @image_text_extractor = image_text_extractor
9
+ @resolution = resolution
10
+ end
11
+
12
+ def extract(pdf_path, page_range: nil)
13
+ @renderer.render(
14
+ pdf_path,
15
+ page_range: page_range,
16
+ resolution: @resolution
17
+ ) do |image_paths|
18
+ build_result(
19
+ Array(image_paths),
20
+ page_numbers(page_range, image_paths.length)
21
+ )
22
+ end
23
+ rescue Errors::UnsupportedFileType => e
24
+ failure_result(e, page_range)
25
+ end
26
+
27
+ private
28
+
29
+ def build_result(image_paths, pages)
30
+ results = image_paths.map do |image_path|
31
+ @image_text_extractor.extract(image_path)
32
+ end
33
+
34
+ raw = results
35
+ .map { |result| result[:raw].to_s }
36
+ .reject(&:empty?)
37
+ .join("\f")
38
+
39
+ normalized = results
40
+ .map { |result| result[:normalized].to_s }
41
+ .reject(&:empty?)
42
+ .join("\n\n")
43
+
44
+ warnings = results.flat_map do |result|
45
+ Array(result[:warnings])
46
+ end
47
+
48
+ warnings.unshift(
49
+ {
50
+ code: "pdf_ocr_fallback",
51
+ message: "pdftotext returned no text; PDF pages were processed with OCR",
52
+ component: "text"
53
+ }
54
+ )
55
+
56
+ unless normalized.empty?
57
+ return {
58
+ extracted: true,
59
+ source: ocr_source(results),
60
+ raw: raw,
61
+ normalized: normalized,
62
+ pages: pages,
63
+ lines: results.flat_map { |result| Array(result[:lines]) },
64
+ warnings: warnings
65
+ }
66
+ end
67
+
68
+ warnings << {
69
+ code: "pdf_ocr_empty",
70
+ message: "PDF OCR returned no recognized text",
71
+ component: "text"
72
+ }
73
+
74
+ {
75
+ extracted: false,
76
+ source: ocr_source(results),
77
+ raw: raw,
78
+ normalized: normalized,
79
+ pages: pages,
80
+ lines: [],
81
+ warnings: warnings
82
+ }
83
+ end
84
+
85
+ def ocr_source(results)
86
+ sources = results
87
+ .map { |result| result[:source] }
88
+ .compact
89
+ .uniq
90
+
91
+ sources.one? ? sources.first : "rapidocr"
92
+ end
93
+
94
+ def page_numbers(page_range, rendered_count)
95
+ pages =
96
+ case page_range
97
+ when nil
98
+ [1]
99
+ when Integer
100
+ [page_range]
101
+ when Range
102
+ page_range.to_a
103
+ when Array
104
+ page_range.dup
105
+ else
106
+ []
107
+ end
108
+
109
+ pages.first(rendered_count)
110
+ end
111
+
112
+ def failure_result(error, page_range)
113
+ {
114
+ extracted: false,
115
+ source: "rapidocr",
116
+ raw: "",
117
+ normalized: "",
118
+ pages: page_numbers(page_range, 0),
119
+ lines: [],
120
+ warnings: [
121
+ {
122
+ code: "pdf_ocr_failed",
123
+ message: error.message,
124
+ component: "text"
125
+ }
126
+ ]
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require_relative "../errors"
6
+
7
+ module InvoiceExtractorArca
8
+ module Pdf
9
+ class Renderer
10
+ DEFAULT_PAGE = 1
11
+ DEFAULT_RESOLUTION = 300
12
+
13
+ def initialize(resolver:, runner:)
14
+ @resolver = resolver
15
+ @runner = runner
16
+ end
17
+
18
+ def render(
19
+ pdf_path,
20
+ page_range: nil,
21
+ resolution: DEFAULT_RESOLUTION
22
+ )
23
+ temp_dir = Dir.mktmpdir("invoice_extractor_arca-")
24
+
25
+ image_paths = pages_from(page_range).map do |page|
26
+ render_page(
27
+ pdf_path: pdf_path,
28
+ temp_dir: temp_dir,
29
+ page: page,
30
+ resolution: resolution
31
+ )
32
+ end
33
+
34
+ yield image_paths
35
+ ensure
36
+ FileUtils.rm_rf(temp_dir) if temp_dir
37
+ end
38
+
39
+ private
40
+
41
+ def pages_from(page_range)
42
+ pages =
43
+ case page_range
44
+ when nil
45
+ [DEFAULT_PAGE]
46
+ when Integer
47
+ [page_range]
48
+ when Range
49
+ page_range.to_a
50
+ when Array
51
+ page_range
52
+ else
53
+ raise Errors::UnsupportedFileType,
54
+ "Invalid page_range: #{page_range.inspect}"
55
+ end
56
+
57
+ pages.each { |page| validate_page!(page) }
58
+ pages
59
+ end
60
+
61
+ def validate_page!(page)
62
+ return if page.is_a?(Integer) && page.positive?
63
+
64
+ raise Errors::UnsupportedFileType,
65
+ "Invalid PDF page number: #{page.inspect}"
66
+ end
67
+
68
+ def render_page(pdf_path:, temp_dir:, page:, resolution:)
69
+ pdftoppm = @resolver.resolve!(:pdftoppm)
70
+ output_prefix = File.join(temp_dir, "page-#{page}")
71
+
72
+ result = @runner.run(
73
+ pdftoppm,
74
+ "-png",
75
+ "-r",
76
+ Integer(resolution).to_s,
77
+ "-f",
78
+ page.to_s,
79
+ "-l",
80
+ page.to_s,
81
+ "-singlefile",
82
+ pdf_path,
83
+ output_prefix
84
+ )
85
+
86
+ unless result[:success]
87
+ raise Errors::UnsupportedFileType, result[:stderr]
88
+ end
89
+
90
+ "#{output_prefix}.png"
91
+ rescue ArgumentError
92
+ raise Errors::UnsupportedFileType,
93
+ "Invalid PDF render resolution: #{resolution.inspect}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../text/normalizer"
5
+
6
+ module InvoiceExtractorArca
7
+ module Pdf
8
+ class TextExtractor
9
+ def initialize(
10
+ resolver:,
11
+ runner:,
12
+ normalizer: Text::Normalizer.new
13
+ )
14
+ @resolver = resolver
15
+ @runner = runner
16
+ @normalizer = normalizer
17
+ end
18
+
19
+ def extract(pdf_path, page_range: nil)
20
+ raw = extract_raw(pdf_path, page_range)
21
+ normalized = @normalizer.normalize(raw)
22
+ warnings = []
23
+
24
+ if normalized.empty?
25
+ warnings << {
26
+ code: "pdf_text_empty",
27
+ message: "PDF contains no extractable text",
28
+ component: "text"
29
+ }
30
+ end
31
+
32
+ {
33
+ extracted: !normalized.empty?,
34
+ source: "pdftotext",
35
+ raw: raw,
36
+ normalized: normalized,
37
+ pages: page_numbers(page_range),
38
+ warnings: warnings
39
+ }
40
+ rescue Errors::MissingDependency,
41
+ Errors::PdfTextExtractionFailure => e
42
+ failure_result(e, page_range)
43
+ end
44
+
45
+ private
46
+
47
+ def extract_raw(pdf_path, page_range)
48
+ pdftotext = @resolver.resolve!(:pdftotext)
49
+
50
+ extraction_ranges(page_range)
51
+ .map do |range|
52
+ extract_range(
53
+ pdftotext,
54
+ pdf_path,
55
+ first_page: range[:first],
56
+ last_page: range[:last]
57
+ )
58
+ end
59
+ .join("\f")
60
+ end
61
+
62
+ def extract_range(
63
+ pdftotext,
64
+ pdf_path,
65
+ first_page:,
66
+ last_page:
67
+ )
68
+ command = [
69
+ pdftotext,
70
+ "-layout",
71
+ "-enc",
72
+ "UTF-8"
73
+ ]
74
+
75
+ if first_page
76
+ command.concat([
77
+ "-f",
78
+ first_page.to_s,
79
+ "-l",
80
+ last_page.to_s
81
+ ])
82
+ end
83
+
84
+ command.concat([
85
+ pdf_path.to_s,
86
+ "-"
87
+ ])
88
+
89
+ result = @runner.run(*command)
90
+
91
+ unless result[:success]
92
+ message = result[:stderr].to_s.strip
93
+ message = "pdftotext command failed" if message.empty?
94
+
95
+ raise Errors::PdfTextExtractionFailure, message
96
+ end
97
+
98
+ result[:stdout].to_s
99
+ end
100
+
101
+ def extraction_ranges(page_range)
102
+ case page_range
103
+ when nil
104
+ [
105
+ {
106
+ first: nil,
107
+ last: nil
108
+ }
109
+ ]
110
+ when Integer
111
+ validate_page!(page_range)
112
+
113
+ [
114
+ {
115
+ first: page_range,
116
+ last: page_range
117
+ }
118
+ ]
119
+ when Range
120
+ pages = page_range.to_a
121
+ pages.each { |page| validate_page!(page) }
122
+
123
+ [
124
+ {
125
+ first: pages.first,
126
+ last: pages.last
127
+ }
128
+ ]
129
+ when Array
130
+ page_range.map do |page|
131
+ validate_page!(page)
132
+
133
+ {
134
+ first: page,
135
+ last: page
136
+ }
137
+ end
138
+ else
139
+ raise Errors::PdfTextExtractionFailure,
140
+ "Invalid page_range: #{page_range.inspect}"
141
+ end
142
+ end
143
+
144
+ def page_numbers(page_range)
145
+ case page_range
146
+ when nil
147
+ nil
148
+ when Integer
149
+ [page_range]
150
+ when Range
151
+ page_range.to_a
152
+ when Array
153
+ page_range.dup
154
+ end
155
+ end
156
+
157
+ def validate_page!(page)
158
+ return if page.is_a?(Integer) && page.positive?
159
+
160
+ raise Errors::PdfTextExtractionFailure,
161
+ "Invalid PDF page number: #{page.inspect}"
162
+ end
163
+
164
+ def failure_result(error, page_range)
165
+ {
166
+ extracted: false,
167
+ source: "pdftotext",
168
+ raw: "",
169
+ normalized: "",
170
+ pages: page_numbers(page_range),
171
+ warnings: [
172
+ {
173
+ code: "pdf_text_extraction_failed",
174
+ message: error.message,
175
+ component: "text"
176
+ }
177
+ ]
178
+ }
179
+ end
180
+ end
181
+ end
182
+ end