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