acroforge 0.1.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 +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +217 -0
- data/Rakefile +10 -0
- data/acroforge.gemspec +37 -0
- data/exe/acroforge +5 -0
- data/lib/acroforge/all_text_processor.rb +126 -0
- data/lib/acroforge/annotator.rb +137 -0
- data/lib/acroforge/cli.rb +351 -0
- data/lib/acroforge/constants.rb +46 -0
- data/lib/acroforge/engine.rb +869 -0
- data/lib/acroforge/labels.rb +112 -0
- data/lib/acroforge/preparer.rb +103 -0
- data/lib/acroforge/relabeler.rb +179 -0
- data/lib/acroforge/schema.rb +208 -0
- data/lib/acroforge/validator.rb +37 -0
- data/lib/acroforge/version.rb +5 -0
- data/lib/acroforge.rb +18 -0
- data/sig/acroforge.rbs +4 -0
- metadata +81 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
|
|
5
|
+
module AcroForge
|
|
6
|
+
# Cleans up human-readable labels extracted from PDFs.
|
|
7
|
+
#
|
|
8
|
+
# PDF text extraction often produces broken word fragments (e.g. a ligature
|
|
9
|
+
# like "fi" gets split, producing "Tax Identi fi cation No.") and labels
|
|
10
|
+
# rendered in different casing conventions across vendors (ALL UPPER, mixed,
|
|
11
|
+
# sentence case). This module normalizes both: it fixes the typo fragments
|
|
12
|
+
# using Constants::TYPO_PHRASE_REPLACEMENTS and converts the result to
|
|
13
|
+
# consistent title case. Used by Engine, Schema, and Relabeler so the same
|
|
14
|
+
# corrections appear in the verbose log, schema variations, and mapping meta.
|
|
15
|
+
module Labels
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Words that conventionally stay lowercase inside a title (except when
|
|
19
|
+
# they're the first or last word).
|
|
20
|
+
TITLE_CASE_CONNECTORS = %w[
|
|
21
|
+
a an the and but or nor for so yet
|
|
22
|
+
of at by in on to up from with as vs
|
|
23
|
+
].to_set
|
|
24
|
+
|
|
25
|
+
def humanize(label)
|
|
26
|
+
return label unless label.is_a?(String) && !label.empty?
|
|
27
|
+
|
|
28
|
+
result = fix_typos(label)
|
|
29
|
+
result = title_case(result)
|
|
30
|
+
result.gsub(/\s+/, " ").strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Fix snake_case typo patterns from Constants::TYPO_PHRASE_REPLACEMENTS in
|
|
34
|
+
# the human-readable label too. "Identi fi cation" -> "Identification".
|
|
35
|
+
def fix_typos(label)
|
|
36
|
+
result = label.dup
|
|
37
|
+
Constants::TYPO_PHRASE_REPLACEMENTS.each do |bad, good|
|
|
38
|
+
parts = bad.split("_").reject(&:empty?).map { |p| Regexp.escape(p) }
|
|
39
|
+
next if parts.empty?
|
|
40
|
+
pattern = /\b#{parts.join('\s+')}\b/i
|
|
41
|
+
result = result.gsub(pattern) do |match|
|
|
42
|
+
replacement = good.tr("_", " ")
|
|
43
|
+
if match[0] == match[0].upcase
|
|
44
|
+
replacement[0].upcase + (replacement[1..] || "")
|
|
45
|
+
else
|
|
46
|
+
replacement
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Convert a label to standard title case:
|
|
54
|
+
# - First and last words always capitalized
|
|
55
|
+
# - Conventional connectors (of, the, to, ...) lowercased mid-label
|
|
56
|
+
# - All other words capitalized on the first letter
|
|
57
|
+
# - Short all-uppercase tokens (<= 3 chars) preserved as acronyms
|
|
58
|
+
# (GHC, DOB, PDF, ID stay as-is; "DDMMYYYY" also preserved as a format)
|
|
59
|
+
# - A word immediately following an opening "(" or "[" is treated as
|
|
60
|
+
# starting a fresh title, so its first letter capitalizes even if it
|
|
61
|
+
# would otherwise be a connector ("(For Disbursement)" not "(for ...)")
|
|
62
|
+
def title_case(text)
|
|
63
|
+
words = text.split(/(\s+)/) # preserve whitespace between words
|
|
64
|
+
content_indices = words.each_index.select { |i| words[i].match?(/\S/) }
|
|
65
|
+
first_idx = content_indices.first
|
|
66
|
+
last_idx = content_indices.last
|
|
67
|
+
|
|
68
|
+
words.each_with_index.map do |word, i|
|
|
69
|
+
next word if word.match?(/^\s*$/)
|
|
70
|
+
|
|
71
|
+
if acronym?(word)
|
|
72
|
+
word
|
|
73
|
+
elsif i == first_idx || i == last_idx || word.start_with?("(", "[", '"', "'")
|
|
74
|
+
capitalize_first(word)
|
|
75
|
+
elsif TITLE_CASE_CONNECTORS.include?(strip_punct(word).downcase)
|
|
76
|
+
word.downcase
|
|
77
|
+
else
|
|
78
|
+
capitalize_first(word)
|
|
79
|
+
end
|
|
80
|
+
end.join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Treat as an acronym if it's all uppercase AND short (<= 3 chars), OR
|
|
84
|
+
# if it has 4+ all-upper letters AND looks like a format/code rather than
|
|
85
|
+
# a word (e.g., "DDMMYYYY"). Mixed letters & digits also count.
|
|
86
|
+
def acronym?(word)
|
|
87
|
+
core = strip_punct(word)
|
|
88
|
+
return false if core.empty?
|
|
89
|
+
return true if core.length <= 3 && core == core.upcase && core.match?(/[A-Z]/)
|
|
90
|
+
# Longer all-upper tokens: keep as acronym only if they contain digits
|
|
91
|
+
# (e.g. "DDMMYYYY", "ID2024") or repeat a pattern that suggests format code.
|
|
92
|
+
return true if core.length >= 4 && core == core.upcase && core.match?(/\d|^([A-Z])\1+/)
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def strip_punct(word)
|
|
97
|
+
word.gsub(/[[:punct:]]/, "")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def capitalize_first(word)
|
|
101
|
+
return word if word.empty?
|
|
102
|
+
# Preserve trailing/embedded punctuation; only fix the casing of the
|
|
103
|
+
# alphabetic part.
|
|
104
|
+
word.sub(/^([[:punct:]]*)([A-Za-z])(.*)$/) do
|
|
105
|
+
prefix = ::Regexp.last_match(1)
|
|
106
|
+
first = ::Regexp.last_match(2).upcase
|
|
107
|
+
rest = ::Regexp.last_match(3).downcase
|
|
108
|
+
"#{prefix}#{first}#{rest}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "hexapdf"
|
|
5
|
+
require_relative "engine"
|
|
6
|
+
|
|
7
|
+
module AcroForge
|
|
8
|
+
# Resolves PDF-internal naming conflicts so they don't get in the way of
|
|
9
|
+
# the human-review workflow.
|
|
10
|
+
#
|
|
11
|
+
# Some PDFs have multiple AcroForm fields sharing the same :T name (e.g.,
|
|
12
|
+
# three separate fields all literally named "date"). YAML mappings can't
|
|
13
|
+
# represent that cleanly — the engine has to fall back to synthetic
|
|
14
|
+
# "date#1", "date#2" suffixes. Preparer mutates the PDF up front to give
|
|
15
|
+
# each duplicate a unique name based on the spatial heuristic's proposal,
|
|
16
|
+
# so subsequent commands (bootstrap, relabel apply) see a clean PDF.
|
|
17
|
+
#
|
|
18
|
+
# Single responsibility: rename duplicate-named fields. Fields with
|
|
19
|
+
# already-unique names are never touched, regardless of what the heuristic
|
|
20
|
+
# proposes for them.
|
|
21
|
+
module Preparer
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def prepare!(pdf_path, out: nil, schema: {})
|
|
25
|
+
out ||= pdf_path
|
|
26
|
+
|
|
27
|
+
proposals = nil
|
|
28
|
+
Dir.mktmpdir do |tmp|
|
|
29
|
+
engine = AcroForge::Engine.new(pdf_path, schema: schema, normalized_dir: tmp)
|
|
30
|
+
engine.compile!
|
|
31
|
+
proposals = engine.field_proposals
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Group proposals by their ORIGINAL field name (strip any #N suffix).
|
|
35
|
+
# Any name appearing more than once is a duplicate that needs resolving.
|
|
36
|
+
grouped = proposals.group_by { |p| base_name(p[:pdf_field_name]) }
|
|
37
|
+
duplicates = grouped.select { |_, occs| occs.length > 1 }
|
|
38
|
+
|
|
39
|
+
if duplicates.empty?
|
|
40
|
+
# Nothing to do. Don't rewrite the file when out == in.
|
|
41
|
+
FileUtils.cp(pdf_path, out) if out != pdf_path
|
|
42
|
+
return {duplicate_groups: 0, renamed: 0, skipped: 0, out_path: out}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
doc = HexaPDF::Document.open(pdf_path)
|
|
46
|
+
form = doc.acro_form(create: false)
|
|
47
|
+
raise RelabelError, "PDF has no AcroForm: #{pdf_path}" unless form
|
|
48
|
+
|
|
49
|
+
field_index = AcroForge::Engine.field_index(form)
|
|
50
|
+
# Names already in use by NON-duplicate fields; we can't collide with them.
|
|
51
|
+
reserved = field_index.keys.reject { |k| k.include?("#") }.to_set
|
|
52
|
+
duplicates.each_key { |base| reserved.delete(base) }
|
|
53
|
+
|
|
54
|
+
renamed = 0
|
|
55
|
+
skipped = 0
|
|
56
|
+
|
|
57
|
+
duplicates.each_value do |occurrences|
|
|
58
|
+
occurrences.each do |proposal|
|
|
59
|
+
field = field_index[proposal[:pdf_field_name]]
|
|
60
|
+
unless field
|
|
61
|
+
skipped += 1
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
proposed = proposal[:canonical_key]
|
|
66
|
+
unless proposed
|
|
67
|
+
skipped += 1
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
target = unique_target(proposed.to_s, reserved)
|
|
72
|
+
reserved.add(target)
|
|
73
|
+
field[:T] = target
|
|
74
|
+
field[:TU] = target
|
|
75
|
+
renamed += 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
doc.write(out)
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
duplicate_groups: duplicates.size,
|
|
83
|
+
renamed: renamed,
|
|
84
|
+
skipped: skipped,
|
|
85
|
+
out_path: out
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def base_name(synthetic_name)
|
|
90
|
+
synthetic_name.to_s.sub(/#\d+\z/, "")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unique_target(target, reserved)
|
|
94
|
+
return target unless reserved.include?(target)
|
|
95
|
+
counter = 1
|
|
96
|
+
loop do
|
|
97
|
+
candidate = "#{target}_#{counter}"
|
|
98
|
+
return candidate unless reserved.include?(candidate)
|
|
99
|
+
counter += 1
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "time"
|
|
6
|
+
require "hexapdf"
|
|
7
|
+
require_relative "engine"
|
|
8
|
+
require_relative "schema"
|
|
9
|
+
require_relative "version"
|
|
10
|
+
|
|
11
|
+
module AcroForge
|
|
12
|
+
class RelabelError < StandardError; end
|
|
13
|
+
|
|
14
|
+
module Relabeler
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
KEY_REGEX = /\A[a-z][a-z0-9_]*\z/
|
|
18
|
+
|
|
19
|
+
def apply!(pdf_path, mapping_path)
|
|
20
|
+
data = YAML.load_file(mapping_path) || {}
|
|
21
|
+
entries = data.reject { |k, _| k.to_s.start_with?("_") }
|
|
22
|
+
|
|
23
|
+
validate!(entries)
|
|
24
|
+
|
|
25
|
+
doc = HexaPDF::Document.open(pdf_path)
|
|
26
|
+
form = doc.acro_form(create: false)
|
|
27
|
+
raise RelabelError, "PDF has no AcroForm: #{pdf_path}" unless form
|
|
28
|
+
|
|
29
|
+
renamed = 0
|
|
30
|
+
disambiguated = 0
|
|
31
|
+
skipped_null = 0
|
|
32
|
+
stale = 0
|
|
33
|
+
|
|
34
|
+
# Build a synthetic-name -> field index using the same naming scheme
|
|
35
|
+
# the engine emits during compile!. This handles PDFs where multiple
|
|
36
|
+
# fields share the same :T name: the mapping refers to "date",
|
|
37
|
+
# "date#1", "date#2", and each one resolves to the right field.
|
|
38
|
+
field_index = AcroForge::Engine.field_index(form)
|
|
39
|
+
|
|
40
|
+
claimed = {}
|
|
41
|
+
entries.each do |pdf_name, entry|
|
|
42
|
+
key = entry["key"]
|
|
43
|
+
if key.nil? || key.to_s.empty?
|
|
44
|
+
skipped_null += 1
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
field = field_index[pdf_name]
|
|
49
|
+
unless field
|
|
50
|
+
stale += 1
|
|
51
|
+
warn "acroforge: stale entry #{pdf_name.inspect} not found in PDF (skipping)"
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
target = key.to_s
|
|
56
|
+
counter = 1
|
|
57
|
+
while claimed.key?(target)
|
|
58
|
+
target = "#{key}_#{counter}"
|
|
59
|
+
counter += 1
|
|
60
|
+
end
|
|
61
|
+
disambiguated += 1 if target != key.to_s
|
|
62
|
+
claimed[target] = true
|
|
63
|
+
|
|
64
|
+
field[:T] = target
|
|
65
|
+
field[:TU] = target
|
|
66
|
+
renamed += 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
doc.write(pdf_path)
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
total: entries.size,
|
|
73
|
+
renamed: renamed,
|
|
74
|
+
disambiguated: disambiguated,
|
|
75
|
+
skipped_null: skipped_null,
|
|
76
|
+
stale: stale
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate!(entries)
|
|
81
|
+
entries.each do |pdf_name, entry|
|
|
82
|
+
raise RelabelError, "reserved sentinel: #{pdf_name.inspect}" if pdf_name.to_s.start_with?("_")
|
|
83
|
+
key = entry["key"]
|
|
84
|
+
next if key.nil? || key.to_s.empty?
|
|
85
|
+
unless key.to_s.match?(KEY_REGEX)
|
|
86
|
+
raise RelabelError, "invalid key #{key.inspect} for field #{pdf_name.inspect}: must match #{KEY_REGEX.inspect}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Write a mapping YAML proposing semantic names for every AcroForm field.
|
|
92
|
+
#
|
|
93
|
+
# If `engine:` is given, the caller has already compiled an engine and
|
|
94
|
+
# we use its proposals directly (no second compile). This lets callers
|
|
95
|
+
# like the CLI's `bootstrap` subcommand share one compile pass with
|
|
96
|
+
# Schema.infer instead of running the engine twice.
|
|
97
|
+
def propose(pdf_path, out:, schema: {}, mode: :merge, engine: nil)
|
|
98
|
+
existing = (mode == :merge && File.exist?(out)) ? YAML.load_file(out) : nil
|
|
99
|
+
|
|
100
|
+
proposals = if engine
|
|
101
|
+
engine.field_proposals
|
|
102
|
+
else
|
|
103
|
+
Dir.mktmpdir do |tmp|
|
|
104
|
+
e = AcroForge::Engine.new(pdf_path, schema: schema, normalized_dir: tmp)
|
|
105
|
+
e.compile!
|
|
106
|
+
e.field_proposals
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sorted = proposals.sort_by { |p| [p[:page], -p[:y], p[:x]] }
|
|
111
|
+
entries = sorted.each_with_object({}) do |p, acc|
|
|
112
|
+
acc[p[:pdf_field_name]] = build_entry(p, existing&.[](p[:pdf_field_name]))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
File.write(out, render_yaml(pdf_path, entries))
|
|
116
|
+
|
|
117
|
+
mapped = entries.values.count { |e| !e["key"].nil? && !e["key"].to_s.empty? }
|
|
118
|
+
{
|
|
119
|
+
total: entries.size,
|
|
120
|
+
mapped: mapped,
|
|
121
|
+
unmapped: entries.size - mapped,
|
|
122
|
+
out_path: out
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_entry(proposal, prior)
|
|
127
|
+
proposed_key = proposal[:canonical_key]&.to_s
|
|
128
|
+
proposed_type = infer_type(proposal).to_s
|
|
129
|
+
|
|
130
|
+
key_value = prior&.key?("key") ? prior["key"] : proposed_key
|
|
131
|
+
type_value = prior&.key?("type") ? prior["type"] : proposed_type
|
|
132
|
+
|
|
133
|
+
meta = {
|
|
134
|
+
"raw_label" => AcroForge::Schema.humanize_label(proposal[:raw_label]),
|
|
135
|
+
"confidence" => proposal[:confidence].to_s,
|
|
136
|
+
"section" => proposal[:section]&.to_s,
|
|
137
|
+
"page" => proposal[:page]
|
|
138
|
+
}
|
|
139
|
+
options = proposal[:options]&.transform_keys(&:to_s)
|
|
140
|
+
meta["options"] = options if options
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
"key" => key_value,
|
|
144
|
+
"type" => type_value,
|
|
145
|
+
"meta" => meta
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def infer_type(proposal)
|
|
150
|
+
case proposal[:pdf_field_type]
|
|
151
|
+
when :button
|
|
152
|
+
((proposal[:options]&.size || 0) > 1) ? :select : :boolean
|
|
153
|
+
when :choice
|
|
154
|
+
:select
|
|
155
|
+
else
|
|
156
|
+
label = proposal[:raw_label].to_s.downcase
|
|
157
|
+
case label
|
|
158
|
+
when /amount|salary|income|balance|fee|tier3/ then :money
|
|
159
|
+
when /\bdate\b|birth|expiry|employed/ then :date
|
|
160
|
+
when /email/ then :email
|
|
161
|
+
when /years|tenor|number of|\bno\.?\b/ then :number
|
|
162
|
+
else :string
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_yaml(pdf_path, entries)
|
|
168
|
+
banner = {
|
|
169
|
+
"_meta" => {
|
|
170
|
+
"source_pdf" => pdf_path,
|
|
171
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
172
|
+
"acroforge_version" => AcroForge::VERSION,
|
|
173
|
+
"total_fields" => entries.size
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
YAML.dump(banner.merge(entries))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "engine"
|
|
6
|
+
require_relative "labels"
|
|
7
|
+
|
|
8
|
+
module AcroForge
|
|
9
|
+
module Schema
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def load(path)
|
|
13
|
+
raw = case File.extname(path).downcase
|
|
14
|
+
when ".yml", ".yaml"
|
|
15
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
|
|
16
|
+
when ".json"
|
|
17
|
+
JSON.parse(File.read(path), symbolize_names: false)
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "unknown schema file extension: #{path.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
normalize(symbolize_schema(raw))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dump(schema, path)
|
|
26
|
+
stringified = stringify_schema(schema)
|
|
27
|
+
case File.extname(path).downcase
|
|
28
|
+
when ".yml", ".yaml"
|
|
29
|
+
File.write(path, YAML.dump(stringified))
|
|
30
|
+
when ".json"
|
|
31
|
+
File.write(path, JSON.pretty_generate(stringified))
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError, "unknown schema file extension: #{path.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def symbolize_schema(raw_hash)
|
|
38
|
+
return {} if raw_hash.nil? || raw_hash.empty?
|
|
39
|
+
|
|
40
|
+
raw_hash.each_with_object({}) do |(key, value), out|
|
|
41
|
+
out[key.to_sym] = symbolize_entry(value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def symbolize_entry(entry)
|
|
46
|
+
return entry unless entry.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
result = {}
|
|
49
|
+
entry.each do |k, v|
|
|
50
|
+
sym_k = k.to_sym
|
|
51
|
+
result[sym_k] = case sym_k
|
|
52
|
+
when :type
|
|
53
|
+
v.is_a?(String) ? v.to_sym : v
|
|
54
|
+
when :options
|
|
55
|
+
if v.is_a?(Array)
|
|
56
|
+
v.map { |item| item.is_a?(String) ? item.to_sym : item }
|
|
57
|
+
else
|
|
58
|
+
v
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
v
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stringify_schema(schema)
|
|
68
|
+
schema.each_with_object({}) do |(key, value), out|
|
|
69
|
+
out[key.to_s] = stringify_entry(value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stringify_entry(entry)
|
|
74
|
+
return entry unless entry.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
result = {}
|
|
77
|
+
entry.each do |k, v|
|
|
78
|
+
str_k = k.to_s
|
|
79
|
+
result[str_k] = case k.to_sym
|
|
80
|
+
when :type
|
|
81
|
+
v.is_a?(Symbol) ? v.to_s : v
|
|
82
|
+
when :options
|
|
83
|
+
if v.is_a?(Array)
|
|
84
|
+
v.map { |item| item.is_a?(Symbol) ? item.to_s : item }
|
|
85
|
+
else
|
|
86
|
+
v
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
v
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Infer a schema from a PDF.
|
|
96
|
+
#
|
|
97
|
+
# If `engine:` is given, the caller has already compiled an engine and
|
|
98
|
+
# we use its proposals directly. This lets callers (notably the CLI's
|
|
99
|
+
# `bootstrap` subcommand) avoid a redundant second compile when they
|
|
100
|
+
# also want to call Relabeler.propose on the same PDF.
|
|
101
|
+
def infer(pdf_path, sections: [], engine: nil)
|
|
102
|
+
return aggregate_proposals(engine.field_proposals) if engine
|
|
103
|
+
|
|
104
|
+
require "tmpdir"
|
|
105
|
+
Dir.mktmpdir do |tmp|
|
|
106
|
+
e = AcroForge::Engine.new(pdf_path, sections: sections, normalized_dir: tmp)
|
|
107
|
+
e.compile!
|
|
108
|
+
aggregate_proposals(e.field_proposals)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def aggregate_proposals(proposals)
|
|
113
|
+
proposals.each_with_object({}) do |p, schema|
|
|
114
|
+
next if p[:canonical_key].nil?
|
|
115
|
+
|
|
116
|
+
key = p[:canonical_key].to_sym
|
|
117
|
+
schema[key] ||= {type: infer_type(p), variations: []}
|
|
118
|
+
if p[:raw_label]
|
|
119
|
+
cleaned = humanize_label(p[:raw_label])
|
|
120
|
+
schema[key][:variations] << cleaned unless schema[key][:variations].include?(cleaned)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if p[:pdf_field_type] == :button && p[:options]
|
|
124
|
+
schema[key][:options] = p[:options].keys.map(&:to_sym).uniq
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Thin delegator. The real implementation lives in AcroForge::Labels so
|
|
130
|
+
# the Engine can apply it at the source (right after the spatial heuristic
|
|
131
|
+
# picks a label) without creating a circular require between engine.rb
|
|
132
|
+
# and schema.rb.
|
|
133
|
+
def humanize_label(label)
|
|
134
|
+
AcroForge::Labels.humanize(label)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def infer_type(proposal)
|
|
138
|
+
case proposal[:pdf_field_type]
|
|
139
|
+
when :button
|
|
140
|
+
((proposal[:options]&.size || 0) > 1) ? :select : :boolean
|
|
141
|
+
when :choice
|
|
142
|
+
:select
|
|
143
|
+
else
|
|
144
|
+
label = proposal[:raw_label].to_s.downcase
|
|
145
|
+
case label
|
|
146
|
+
when /amount|salary|income|balance|fee|tier3/ then :money
|
|
147
|
+
when /\bdate\b|birth|expiry|employed/ then :date
|
|
148
|
+
when /email/ then :email
|
|
149
|
+
when /years|tenor|number of|\bno\.?\b/ then :number
|
|
150
|
+
else :string
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Merge a mapping file's hand-reviewed decisions back into a schema.
|
|
156
|
+
# Each non-null mapping entry contributes a canonical key (stripped of
|
|
157
|
+
# any _N collision suffix), its type, and its raw_label as a variation.
|
|
158
|
+
# Existing schema entries keep their type but gain new variations;
|
|
159
|
+
# missing entries are created. Returns the merged schema hash.
|
|
160
|
+
def merge(schema, mapping_entries)
|
|
161
|
+
result = schema.each_with_object({}) do |(k, v), out|
|
|
162
|
+
out[k] = v.is_a?(Hash) ? v.dup.tap { |d| d[:variations] = (d[:variations] || []).dup } : v
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
mapping_entries.each do |pdf_field_name, entry|
|
|
166
|
+
next if pdf_field_name.to_s.start_with?("_")
|
|
167
|
+
next unless entry.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
key_str = entry["key"]
|
|
170
|
+
next if key_str.nil? || key_str.to_s.empty?
|
|
171
|
+
|
|
172
|
+
# Strip the _N collision suffix the engine appends when multiple
|
|
173
|
+
# fields map to the same canonical key (full_name_1, full_name_2).
|
|
174
|
+
canonical = key_str.to_s.sub(/_\d+\z/, "").to_sym
|
|
175
|
+
|
|
176
|
+
type_str = entry["type"]
|
|
177
|
+
type_sym = type_str.is_a?(String) ? type_str.to_sym : type_str
|
|
178
|
+
|
|
179
|
+
result[canonical] ||= {type: type_sym || :string, variations: []}
|
|
180
|
+
# Don't overwrite an existing type unless one is actually given
|
|
181
|
+
result[canonical][:type] = type_sym if type_sym
|
|
182
|
+
|
|
183
|
+
raw_label = entry.dig("meta", "raw_label")
|
|
184
|
+
if raw_label && !raw_label.to_s.empty?
|
|
185
|
+
variations = result[canonical][:variations] ||= []
|
|
186
|
+
variations << raw_label.to_s unless variations.include?(raw_label.to_s)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
result
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def normalize(input)
|
|
194
|
+
return {} if input.nil? || input.empty?
|
|
195
|
+
|
|
196
|
+
input.each_with_object({}) do |(key, value), out|
|
|
197
|
+
out[key] = case value
|
|
198
|
+
when Array
|
|
199
|
+
{type: :string, variations: value}
|
|
200
|
+
when Hash
|
|
201
|
+
value
|
|
202
|
+
else
|
|
203
|
+
raise ArgumentError, "Schema entry for #{key.inspect} must be an Array or Hash, got #{value.class}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module AcroForge
|
|
7
|
+
class ValidationError < StandardError; end
|
|
8
|
+
|
|
9
|
+
module Validator
|
|
10
|
+
def self.valid?(value, type, options = [])
|
|
11
|
+
return true if value.nil? || value.to_s.empty?
|
|
12
|
+
|
|
13
|
+
case type
|
|
14
|
+
when :money
|
|
15
|
+
value.to_s.gsub(/[$,]/, "").match?(/^\d+(\.\d+)?$/)
|
|
16
|
+
when :date
|
|
17
|
+
begin
|
|
18
|
+
Date.parse(value.to_s)
|
|
19
|
+
true
|
|
20
|
+
rescue ArgumentError, TypeError
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
when :email
|
|
24
|
+
value.to_s.match?(URI::MailTo::EMAIL_REGEXP)
|
|
25
|
+
when :number
|
|
26
|
+
value.to_s.gsub(/[\s-]/, "").match?(/^\d+$/)
|
|
27
|
+
when :boolean
|
|
28
|
+
["true", "false", "yes", "no", "1", "0", "on", "off"].include?(value.to_s.downcase)
|
|
29
|
+
when :select
|
|
30
|
+
val_str = value.to_s.downcase
|
|
31
|
+
options.any? { |o| o.to_s.downcase == val_str }
|
|
32
|
+
else
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/acroforge.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "acroforge/version"
|
|
4
|
+
require_relative "acroforge/constants"
|
|
5
|
+
|
|
6
|
+
module AcroForge
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require_relative "acroforge/all_text_processor"
|
|
11
|
+
require_relative "acroforge/labels"
|
|
12
|
+
require_relative "acroforge/validator"
|
|
13
|
+
require_relative "acroforge/engine"
|
|
14
|
+
require_relative "acroforge/schema"
|
|
15
|
+
require_relative "acroforge/relabeler"
|
|
16
|
+
require_relative "acroforge/annotator"
|
|
17
|
+
require_relative "acroforge/preparer"
|
|
18
|
+
require_relative "acroforge/cli"
|
data/sig/acroforge.rbs
ADDED