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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcroForge
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module AcroForge
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end