acrofill 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 28d2db0f5f96d10602a2c8dd994a9957665df1426cffdbc045b3860c675e504e
4
+ data.tar.gz: c683b238b9ff31d182ad316712e3982c3ffa6dd38a92fa6547efca3e784d02c4
5
+ SHA512:
6
+ metadata.gz: fb49f9bb0fe9efd45665d92c09c845a4156695b457bd9a3fddc13e7496a1221a009732147958f2a753130ebd5dc2f78b569b0ad117bf10ffa3262ba85bd3464e
7
+ data.tar.gz: 492ac4103cfa6fb500c70a648a94f479cf7411eb2293d5b7fc29ab7753e90bcd3a52f244facd94732e237f410a0038a9ca2a0ed2e7321a6ae4cc907744a4c265
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 stiig
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # Acrofill
2
+
3
+ Pure-Ruby PDF form (AcroForm) filling and flattening. No pdftk, no Java,
4
+ no native extensions — drop it into any Ruby or Rails project and fill
5
+ PDF templates in-process.
6
+
7
+ ```ruby
8
+ Acrofill.fill_form('template.pdf', 'out.pdf',
9
+ { 'Full Name' => 'Jane Roe', 'agree' => 'Yes' },
10
+ flatten: true)
11
+ ```
12
+
13
+ Parsing is delegated to [pdf-reader](https://github.com/yob/pdf-reader)
14
+ (MIT), which handles classic and cross-reference-stream PDFs, object
15
+ streams, and every standard filter. Acrofill adds the write side: field
16
+ value setting, appearance-stream generation, checkbox/radio state
17
+ selection, flattening, and a full document serializer.
18
+
19
+ ## Why
20
+
21
+ The usual Ruby answer to "fill this PDF form" is the `pdf-forms` gem,
22
+ which shells out to [pdftk](https://gitlab.com/pdftk-java/pdftk) — a
23
+ Java application you must install on every machine and container, and
24
+ pay a JVM start-up for on every call. The main in-process alternative,
25
+ HexaPDF, is AGPL/commercial. Acrofill is MIT-licensed and fills a form
26
+ in a few milliseconds in-process.
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem 'acrofill'
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```ruby
38
+ require 'acrofill'
39
+
40
+ # Discover fields in a template
41
+ Acrofill.field_names('form.pdf')
42
+ # => ["Full Name", "amount", "agree", "color", "notes"]
43
+
44
+ Acrofill.fields('form.pdf').map { |f| [f.name, f.type, f.states] }
45
+ # => [["Full Name", :Tx, nil], ["agree", :Btn, [:Yes]], ["color", :Btn, [:Red, :Blue]], ...]
46
+
47
+ # Fill: text fields take strings, checkboxes/radios take a state name
48
+ Acrofill.fill_form('form.pdf', 'out.pdf', {
49
+ 'Full Name' => 'Jane Roe',
50
+ 'agree' => 'Yes', # checkbox: state name, or anything truthy
51
+ 'color' => 'Blue', # radio group: state name
52
+ 'notes' => "line one\nline two" # multiline fields wrap automatically
53
+ })
54
+
55
+ # Flatten: burn values into page content and drop the interactive form
56
+ Acrofill.fill_form('form.pdf', 'out.pdf', data, flatten: true)
57
+
58
+ # Filling the same template many times (the typical server pattern)?
59
+ # Parse it once — each subsequent fill skips parsing entirely (~3-8x
60
+ # faster per fill) and works on its own copy, so it is thread-safe:
61
+ template = Acrofill::Template.new('form.pdf')
62
+ template.fill_form('out1.pdf', { 'Full Name' => 'Jane' }, flatten: true)
63
+ template.fill_form('out2.pdf', { 'Full Name' => 'John' }, flatten: true)
64
+ ```
65
+
66
+ ### Drop-in for pdf-forms
67
+
68
+ `Acrofill.new` accepts (and ignores) a pdftk path, so most `PdfForms`
69
+ call sites work unchanged:
70
+
71
+ ```ruby
72
+ filler = Acrofill.new # was: PdfForms.new('/usr/bin/pdftk')
73
+ filler.fill_form(tpl, out, data, flatten: true)
74
+ ```
75
+
76
+ Unknown field names are silently ignored, matching pdftk.
77
+
78
+ ## Performance
79
+
80
+ Because Acrofill runs in-process, it avoids the JVM (or C++ process)
81
+ start-up that dominates a `pdftk` subprocess call — the exact pattern a
82
+ web app hits when it fills one form per request. Filling **and
83
+ flattening** three real-world government claim forms, 50 iterations
84
+ each, on an Apple M-series laptop against `pdftk-java` 3.3.3:
85
+
86
+ | Form | acrofill | pdftk (java) | speedup |
87
+ |----------------------------|---------:|-------------:|--------:|
88
+ | form A (124 fields, 3 pp) | 49 ms | 192 ms | 3.9× |
89
+ | form B (53 fields, 2 pp) | 16 ms | 124 ms | 7.6× |
90
+ | form C (187 fields, 2 pp) | 47 ms | 154 ms | 3.3× |
91
+ | **weighted total** | | | **4.2×**|
92
+
93
+ Reproduce with your own templates — the benchmark discovers each form's
94
+ fields automatically:
95
+
96
+ ```bash
97
+ ruby benchmark/compare.rb path/to/*.pdf # ITERATIONS=50 by default
98
+ ```
99
+
100
+ ### Self-contained synthetic benchmark
101
+
102
+ `benchmark/synthetic.rb` needs no templates — it generates a form with a
103
+ configurable number of text fields and reports three variants. Because
104
+ `pdftk` (a CLI) has no server mode, the JVM boots on every call; to be
105
+ fair we also measure that start-up cost separately and subtract it, so
106
+ **pdftk warm** models the marginal PDF-processing cost with the runtime
107
+ already hot (a lower bound on any long-lived-pdftk setup):
108
+
109
+ ```
110
+ $ FIELDS=30 ruby benchmark/synthetic.rb
111
+ variant ms/fill vs acrofill
112
+ acrofill (in-process) 3.4 1.0×
113
+ acrofill (cached template) 1.1 0.3×
114
+ pdftk cold (subprocess) 122.3 36.0×
115
+ pdftk warm (JVM excluded) 90.6 26.6×
116
+ (measured pdftk start-up overhead: ~32 ms/call)
117
+ ```
118
+
119
+ Even discounting JVM start-up entirely, Acrofill is ~13–29× faster on
120
+ synthetic forms (the multiplier shrinks as field count grows: ~29× at 30
121
+ fields, ~13× at 80). The gap is largest on simple forms — exactly the
122
+ common case — and start-up is a real per-request cost for any `pdftk`-CLI
123
+ integration. The `pdftk warm` figure is a conservative estimate: `pdftk
124
+ --version` loads fewer classes than an actual fill, so true warm cost is
125
+ somewhat higher than shown.
126
+
127
+ Parsing dominates a one-shot fill (~86% of the time on a real 3-page
128
+ form), which is what `Acrofill::Template` eliminates: on the 124-field
129
+ form above a one-shot fill takes ~47 ms while a cached-template fill
130
+ takes ~6 ms — against pdftk's ~192 ms for the same job.
131
+
132
+ ## Security
133
+
134
+ The PDF **template** is treated as untrusted input (the typical
135
+ "upload a PDF and fill it" case), and so are field names and values.
136
+ Acrofill is pure Ruby with no `eval`/`system`/native calls in its hot
137
+ path, so the worst an input can do is cause an exception — never memory
138
+ corruption or code execution. Specifically it defends against:
139
+
140
+ - **Cyclic and exponentially-shared object graphs** — the page tree and
141
+ field tree walkers carry visited-sets, so a `/Pages` or `/Kids` cycle,
142
+ or a "billion laughs" DAG, terminates in O(objects) instead of hanging
143
+ or overflowing the stack.
144
+ - **Content-stream injection via `/DA`** — the template's default
145
+ appearance string is sanitized: the font name must be a regular PDF
146
+ name and only well-formed colour operators (`g`/`rg`/`k`) are copied
147
+ into generated appearances. Field **values** are always written as
148
+ escaped literals / hex strings, never as operators.
149
+ - **Malformed scalars** — non-finite reals, deeply nested arrays, and
150
+ mistyped dictionaries are clamped or rejected rather than crashing the
151
+ writer.
152
+ - **Encrypted / unparseable input** — rejected up front; every failure
153
+ at the parse boundary surfaces as `Acrofill::Error`, so callers only
154
+ ever rescue one error type.
155
+
156
+ ## Feature matrix
157
+
158
+ Supported:
159
+
160
+ - Text fields (`/Tx`) — hierarchical names (`parent.kid`), inherited
161
+ `/DA`, alignment via `/Q` (left/center/right), auto font size (`0 Tf`),
162
+ shrink-to-fit for overflowing values, multiline fields (`/Ff` bit 13)
163
+ with word wrapping, standard-14 width metrics
164
+ (Helvetica/Courier/Times).
165
+ - Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
166
+ using the template's own appearance states.
167
+ - Choice fields (`/Ch`) — value set and rendered like text.
168
+ - Flattening — every visible widget appearance is stamped into the page
169
+ content; widget annotations and the AcroForm dictionary are removed.
170
+ - Values in any encoding (stored as UTF-16BE when needed; appearances
171
+ render the Windows-1252 subset).
172
+
173
+ Not supported (rejected or ignored, never a hard crash):
174
+
175
+ - Encrypted documents (raise `Acrofill::Error`), XFA forms, digital
176
+ signatures, JavaScript actions.
177
+ - Push buttons; comb fields render as plain text; rich text (`/RV`) is
178
+ dropped on fill.
179
+ - Glyphs outside Windows-1252 in generated appearances (stored values
180
+ keep full Unicode; unrenderable glyphs appear as `?`).
181
+
182
+ ## Development
183
+
184
+ ```bash
185
+ bundle install
186
+ bundle exec rake spec
187
+ ```
188
+
189
+ The test suite generates its fixture PDFs from scratch — the repository
190
+ contains no binary files.
191
+
192
+ ## License
193
+
194
+ MIT. See LICENSE.txt.
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acrofill
4
+ # Builds the /AP /N appearance stream (a Form XObject) for a filled
5
+ # text-field widget, honouring the field's /DA string and /Q alignment.
6
+ class Appearance
7
+ PADDING = 2.0
8
+ ASCENT = 0.718 # Helvetica cap-height-ish ascent, em fractions
9
+ DESCENT = 0.207
10
+ # Colour-setting operators allowed in a /DA string, and their operand counts.
11
+ COLOR_OP_ARITY = { 'g' => 1, 'rg' => 3, 'k' => 4 }.freeze
12
+
13
+ def initialize(doc, acroform)
14
+ @doc = doc
15
+ @acroform = acroform
16
+ end
17
+
18
+ # Returns a Reference to the new appearance XObject, or nil when the
19
+ # widget geometry is unusable.
20
+ def build(field_node, widget, value, multiline: false)
21
+ rect = normalized_rect(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
22
+ return nil unless rect
23
+
24
+ width = rect[2] - rect[0]
25
+ height = rect[3] - rect[1]
26
+ return nil if width <= 0 || height <= 0
27
+
28
+ font_name, size, color_ops = parse_da(field_node)
29
+ base_font = base_font_for(font_name)
30
+ align = @doc.inherited_value(field_node, :Q) || @acroform[:Q] || 0
31
+
32
+ body =
33
+ if multiline
34
+ size = 12.0 if size <= 0
35
+ size = size.clamp(2.0, 144.0)
36
+ multiline_body(value, base_font, size, width, height, align)
37
+ else
38
+ text = printable_text(value)
39
+ size = [height * 0.66, 12.0].min if size.zero?
40
+ size = shrink_to_fit(text, base_font, size, width)
41
+ ty = [(height - (size * (ASCENT - DESCENT))) / 2.0, size * DESCENT].max
42
+ "#{fmt(line_x(text, base_font, size, width, align))} #{fmt(ty)} Td\n" \
43
+ "(#{escape_literal(text)}) Tj\n"
44
+ end
45
+
46
+ content = +"/Tx BMC\nq\nBT\n"
47
+ content << "#{color_ops}\n" unless color_ops.empty?
48
+ content << "/#{font_name} #{fmt(size)} Tf\n"
49
+ content << body
50
+ content << "ET\nQ\nEMC\n"
51
+
52
+ dict = {
53
+ Type: :XObject,
54
+ Subtype: :Form,
55
+ FormType: 1,
56
+ BBox: [0, 0, width, height],
57
+ Resources: { Font: { font_name.to_sym => font_ref(font_name) } }
58
+ }
59
+ @doc.add(StreamObject.new(dict, content.b))
60
+ end
61
+
62
+ private
63
+
64
+ def fmt(num)
65
+ Serializer.format_number(num.to_f)
66
+ end
67
+
68
+ def line_x(text, base_font, size, width, align)
69
+ text_width = Metrics.string_width(text, base_font, size)
70
+ case align
71
+ when 1 then [(width - text_width) / 2.0, PADDING].max
72
+ when 2 then [width - PADDING - text_width, PADDING].max
73
+ else PADDING
74
+ end
75
+ end
76
+
77
+ # Greedy word wrap, top-down, honouring explicit line breaks. Lines
78
+ # that would fall below the box are clipped by the BBox.
79
+ def multiline_body(value, base_font, size, width, height, align)
80
+ max_width = width - (2 * PADDING)
81
+ lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
82
+ wrap_line(printable_text(paragraph), base_font, size, max_width)
83
+ end
84
+
85
+ leading = size * 1.15
86
+ first_y = height - PADDING - (size * ASCENT)
87
+ body = "#{fmt(leading)} TL\n"
88
+ previous_x = 0.0
89
+ lines.each_with_index do |line, index|
90
+ x = line_x(line, base_font, size, width, align)
91
+ body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
92
+ body << "(#{escape_literal(line)}) Tj\nT*\n"
93
+ previous_x = x
94
+ end
95
+ body
96
+ end
97
+
98
+ # Greedy wrap. Line and space widths are accumulated incrementally so
99
+ # the cost is O(total characters), not O(words * line-length).
100
+ def wrap_line(text, base_font, size, max_width)
101
+ space = Metrics.string_width(' ', base_font, size)
102
+ lines = ['']
103
+ widths = [0.0]
104
+ text.split.each do |word|
105
+ word_width = Metrics.string_width(word, base_font, size)
106
+ if lines.last.empty?
107
+ lines[-1] = word
108
+ widths[-1] = word_width
109
+ elsif widths.last + space + word_width <= max_width
110
+ lines[-1] = "#{lines.last} #{word}"
111
+ widths[-1] += space + word_width
112
+ else
113
+ lines << word
114
+ widths << word_width
115
+ end
116
+ end
117
+ lines
118
+ end
119
+
120
+ def normalized_rect(rect)
121
+ rect = @doc.deref(rect)
122
+ return nil unless rect.is_a?(Array) && rect.size == 4
123
+
124
+ xs = [rect[0].to_f, rect[2].to_f].sort
125
+ ys = [rect[1].to_f, rect[3].to_f].sort
126
+ [xs[0], ys[0], xs[1], ys[1]]
127
+ end
128
+
129
+ # /DA is e.g. "/Helv 8 Tf 0 g": font + size around Tf, plus a colour.
130
+ # The /DA string is template-controlled and copied into the generated
131
+ # appearance stream, so the font name and colour operators are both
132
+ # sanitized here — otherwise a crafted /DA could inject arbitrary
133
+ # content operators (e.g. rectangle fills) into the output.
134
+ def parse_da(field_node)
135
+ da = @doc.deref(@doc.inherited_value(field_node, :DA)) || @doc.deref(@acroform[:DA]) || '/Helv 0 Tf 0 g'
136
+ return ['Helv', 0.0, '0 g'] unless da.is_a?(String)
137
+
138
+ tokens = da.split
139
+ tf = tokens.index('Tf')
140
+ return ['Helv', 0.0, '0 g'] unless tf && tf >= 2
141
+
142
+ font = sanitize_font(tokens[tf - 2].to_s.delete_prefix('/'))
143
+ size = tokens[tf - 1].to_f
144
+ color = safe_color_ops(tokens[0...(tf - 2)] + tokens[(tf + 1)..])
145
+ [font, size, color]
146
+ end
147
+
148
+ # Only a name made of PDF-regular characters may be written as /Name.
149
+ def sanitize_font(name)
150
+ Serializer::REGULAR_NAME.match?(name) ? name : 'Helv'
151
+ end
152
+
153
+ # Keep only well-formed colour-setting operators (g / rg / k) with the
154
+ # right count of numeric operands immediately before them; drop anything
155
+ # else the /DA might carry.
156
+ def safe_color_ops(tokens)
157
+ ops = []
158
+ tokens.each_with_index do |tok, i|
159
+ arity = COLOR_OP_ARITY[tok]
160
+ next unless arity
161
+
162
+ operands = tokens[[i - arity, 0].max...i]
163
+ next unless operands.size == arity && operands.all? { |o| o =~ /\A-?\d*\.?\d+\z/ }
164
+
165
+ ops << "#{operands.join(' ')} #{tok}"
166
+ end
167
+ ops.empty? ? '0 g' : ops.join(' ')
168
+ end
169
+
170
+ # Fixed sizes that overflow the box are scaled down so the whole value
171
+ # stays visible (Acrobat-style best-fit; pdftk would clip instead).
172
+ def shrink_to_fit(text, base_font, size, width)
173
+ max_width = width - (2 * PADDING)
174
+ text_width = Metrics.string_width(text, base_font, size)
175
+ size *= max_width / text_width if text_width > max_width && text_width.positive?
176
+ size.clamp(2.0, 144.0)
177
+ end
178
+
179
+ # The font resource dictionary from /AcroForm /DR /Font, or {} when the
180
+ # template supplies a malformed (non-dictionary) /DR or /Font.
181
+ def dr_fonts
182
+ dr = @doc.deref(@acroform[:DR])
183
+ return {} unless dr.is_a?(Hash)
184
+
185
+ fonts = @doc.deref(dr[:Font])
186
+ fonts.is_a?(Hash) ? fonts : {}
187
+ end
188
+
189
+ def base_font_for(resource_name)
190
+ font = @doc.deref(dr_fonts[resource_name.to_sym])
191
+ base = font.is_a?(Hash) ? font[:BaseFont].to_s : ''
192
+ base.sub(/\A[A-Z]{6}\+/, '')
193
+ end
194
+
195
+ def font_ref(resource_name)
196
+ entry = dr_fonts[resource_name.to_sym]
197
+ return @doc.ref_for(entry) if entry
198
+
199
+ # Font not present in /DR: register a plain Helvetica.
200
+ @doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
201
+ end
202
+
203
+ def printable_text(value)
204
+ value.to_s.gsub(/[[:space:]]+/, ' ').strip
205
+ .encode('Windows-1252', invalid: :replace, undef: :replace, replace: '?')
206
+ end
207
+
208
+ def escape_literal(text)
209
+ text.b
210
+ .gsub(/[\\()]/) { |ch| "\\#{ch}" }
211
+ .gsub(/[\x00-\x1F]/) { |ch| format('\\%03o', ch.ord) }
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pdf-reader'
4
+
5
+ module Acrofill
6
+ # A new stream object created by acrofill (appearance streams, stamped
7
+ # content). +raw+ holds the final, already-encoded stream bytes.
8
+ StreamObject = Struct.new(:dict, :raw)
9
+
10
+ # In-memory object store for a parsed PDF. All indirect objects are
11
+ # materialized once via pdf-reader (which transparently handles xref
12
+ # streams and object streams), then mutated in place before writing.
13
+ class Document
14
+ attr_reader :objects, :trailer
15
+
16
+ def initialize(path)
17
+ hash = load_object_hash(path)
18
+ raise Error, 'encrypted PDFs are not supported' if hash.trailer[:Encrypt]
19
+
20
+ @objects = {}
21
+ @max_oid = 0
22
+ hash.each do |ref, obj|
23
+ @objects[ref.id] = obj
24
+ @max_oid = ref.id if ref.id > @max_oid
25
+ end
26
+ @trailer = hash.trailer.slice(:Root, :Info, :ID)
27
+ raise Error, 'PDF has no document catalog' unless @trailer[:Root]
28
+ end
29
+
30
+ # A serialized copy of the pristine object graph; see Template.
31
+ def snapshot
32
+ Marshal.dump([@objects, @trailer, @max_oid])
33
+ end
34
+
35
+ # Rebuilds a Document from a snapshot produced by #snapshot. Restoring
36
+ # deep-copies every object, so mutations never leak between fills.
37
+ # Snapshots are an internal format: only feed this data produced by
38
+ # #snapshot in the same process (Marshal is not safe on foreign input).
39
+ def self.restore(snapshot)
40
+ doc = allocate
41
+ # Snapshots are produced by #snapshot in the same process and never
42
+ # accepted from external input, so Marshal here is not a deserialization
43
+ # boundary (see Template docs).
44
+ doc.send(:restore_state, *Marshal.load(snapshot)) # rubocop:disable Security/MarshalLoad
45
+ doc
46
+ end
47
+
48
+ private
49
+
50
+ def restore_state(objects, trailer, max_oid)
51
+ @objects = objects
52
+ @trailer = trailer
53
+ @max_oid = max_oid
54
+ end
55
+
56
+ # The parse boundary for untrusted input: pdf-reader (and the decryptors
57
+ # it calls) can raise a wide assortment of errors on crafted files, so
58
+ # everything is normalized to a single Acrofill::Error type.
59
+ def load_object_hash(path)
60
+ PDF::Reader::ObjectHash.new(path)
61
+ rescue PDF::Reader::EncryptedPDFError => e
62
+ raise Error, "encrypted PDFs are not supported (#{e.message})"
63
+ rescue StandardError => e
64
+ raise Error, "could not parse PDF (#{e.class}: #{e.message})"
65
+ end
66
+
67
+ public
68
+
69
+ # Follows reference chains (ref -> ref -> value is legal PDF), with a
70
+ # hop cap so a reference cycle cannot loop forever.
71
+ def deref(obj)
72
+ hops = 0
73
+ while obj.is_a?(PDF::Reader::Reference)
74
+ return nil if (hops += 1) > 16
75
+
76
+ obj = @objects[obj.id]
77
+ end
78
+ obj
79
+ end
80
+
81
+ # Adds a new indirect object and returns a reference to it.
82
+ def add(obj)
83
+ @max_oid += 1
84
+ @objects[@max_oid] = obj
85
+ PDF::Reader::Reference.new(@max_oid, 0)
86
+ end
87
+
88
+ # Wraps a direct object into an indirect one; passes references through.
89
+ def ref_for(obj)
90
+ obj.is_a?(PDF::Reader::Reference) ? obj : add(obj)
91
+ end
92
+
93
+ def root
94
+ deref(@trailer[:Root])
95
+ end
96
+
97
+ def each_page(node = root[:Pages], seen = {}, &block)
98
+ dict = deref(node)
99
+ return unless dict.is_a?(Hash)
100
+
101
+ # Guard against cyclic and shared (billion-laughs) /Pages trees: a
102
+ # node reachable more than once is walked only the first time.
103
+ if node.is_a?(PDF::Reader::Reference)
104
+ return if seen[node.id]
105
+
106
+ seen[node.id] = true
107
+ end
108
+
109
+ case dict[:Type]
110
+ when :Pages
111
+ (deref(dict[:Kids]) || []).each { |kid| each_page(kid, seen, &block) }
112
+ when :Page
113
+ yield dict
114
+ end
115
+ end
116
+
117
+ # Resolves an attribute inheritable through the /Parent chain
118
+ # (field attributes like /FT, /DA, /Q or page attributes).
119
+ def inherited_value(node, key)
120
+ seen = 0
121
+ while node.is_a?(Hash)
122
+ return node[key] if node.key?(key)
123
+ return nil if (seen += 1) > 64 # cycle guard
124
+
125
+ node = deref(node[:Parent])
126
+ end
127
+ nil
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acrofill
4
+ # Public entry point, signature-compatible with PdfForms#fill_form:
5
+ #
6
+ # Acrofill.new.fill_form(template, output, { 'Field' => 'value' }, flatten: true)
7
+ #
8
+ # Note: PdfForms-specific options (data_format:, utf8_fields:, ...) are
9
+ # accepted and ignored — acrofill needs no FDF and is always UTF-aware.
10
+ class Filler
11
+ # Accepts and ignores a pdftk path argument for drop-in compatibility.
12
+ def initialize(*); end
13
+
14
+ def fill_form(template, destination, data = {}, options = {})
15
+ apply(Document.new(template), destination, data, options)
16
+ end
17
+
18
+ # Field metadata (name, type, current value, checkbox/radio states)
19
+ # without modifying the document.
20
+ def fields(template)
21
+ Form.new(Document.new(template)).fields
22
+ end
23
+
24
+ # Fully-qualified names of all fillable fields.
25
+ def field_names(template)
26
+ fields(template).map(&:name)
27
+ end
28
+
29
+ # Shared fill pipeline, also driven by Template with a restored
30
+ # document. Values are normalized to valid UTF-8 up front so that a
31
+ # mis-encoded input degrades (replacement character) instead of
32
+ # leaking a raw Encoding error from deep inside the appearance code.
33
+ def apply(doc, destination, data, options)
34
+ form = Form.new(doc)
35
+ data.each do |name, value|
36
+ form.fill(normalize(name), normalize(value))
37
+ end
38
+ form.flatten! if options[:flatten] || options['flatten']
39
+ File.binwrite(destination, Writer.new(doc).render)
40
+ destination
41
+ end
42
+
43
+ private
44
+
45
+ def normalize(value)
46
+ return '' if value.nil?
47
+
48
+ value.to_s.encode('UTF-8', invalid: :replace, undef: :replace).scrub
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acrofill
4
+ # The interactive form of a document: field lookup by fully-qualified
5
+ # name, value filling with appearance regeneration, and flattening.
6
+ class Form
7
+ HIDDEN_FLAG = 2
8
+ MULTILINE_FLAG = 1 << 12
9
+ PUSHBUTTON_FLAG = 1 << 16
10
+
11
+ Field = Struct.new(:name, :type, :value, :states, keyword_init: true)
12
+
13
+ def initialize(doc)
14
+ @doc = doc
15
+ @acroform = doc.deref(doc.root[:AcroForm])
16
+ raise Error, 'document has no AcroForm' unless @acroform.is_a?(Hash)
17
+
18
+ @appearance = Appearance.new(doc, @acroform)
19
+ # name => array of { node:, widgets: } groups. Several field dicts may
20
+ # share one fully-qualified name (PDF 32000 §12.7.3.2 treats them as a
21
+ # single logical field, e.g. "SSN" repeated on every page) — filling
22
+ # must update all of them.
23
+ @fields = Hash.new { |h, k| h[k] = [] }
24
+ collect_fields(doc.deref(@acroform[:Fields]) || [], nil, {})
25
+ end
26
+
27
+ # Metadata for every logical field, in document order.
28
+ def fields
29
+ @fields.map do |name, groups|
30
+ type = field_type(groups.first[:node])
31
+ Field.new(
32
+ name: name,
33
+ type: type,
34
+ value: decode_text_string(groups.first[:node][:V]),
35
+ states: type == :Btn ? on_states(all_widgets(groups)) : nil
36
+ )
37
+ end
38
+ end
39
+
40
+ # Sets the field value and rebuilds widget appearances. Unknown names
41
+ # are ignored (returns false), matching pdftk's fill_form behaviour.
42
+ def fill(name, value)
43
+ groups = @fields.fetch(name, [])
44
+ return false if groups.empty?
45
+
46
+ case field_type(groups.first[:node])
47
+ when :Btn then fill_button(groups, value)
48
+ when :Tx, :Ch, nil then groups.each { |group| fill_text(group, value) }.any?
49
+ else false # signatures and unknown types are left untouched
50
+ end
51
+ end
52
+
53
+ # Stamps every visible widget appearance into its page's content and
54
+ # removes the interactive layer, like pdftk's `output ... flatten`.
55
+ def flatten!
56
+ @doc.each_page { |page| flatten_page(page) }
57
+ @doc.root.delete(:AcroForm)
58
+ end
59
+
60
+ private
61
+
62
+ def all_widgets(groups)
63
+ groups.flat_map { |group| group[:widgets] }
64
+ end
65
+
66
+ def field_type(node)
67
+ type = @doc.inherited_value(node, :FT)
68
+ type.is_a?(Symbol) ? type : nil
69
+ end
70
+
71
+ # /Ff is an integer bit set; coerce defensively since malformed input
72
+ # can supply a non-integer here (Array#to_i does not exist).
73
+ def field_flags(node)
74
+ flags = @doc.deref(@doc.inherited_value(node, :Ff))
75
+ flags.is_a?(Integer) ? flags : 0
76
+ end
77
+
78
+ # Annotation /F flags. Must be dereferenced before to_i: on a
79
+ # PDF::Reader::Reference, to_i returns the object *number*.
80
+ def annotation_flags(widget)
81
+ flags = @doc.deref(widget[:F])
82
+ flags.is_a?(Integer) ? flags : 0
83
+ end
84
+
85
+ def fill_text(group, value)
86
+ node = group[:node]
87
+ node[:V] = pdf_text_string(value)
88
+ node.delete(:RV)
89
+ node.delete(:I)
90
+ multiline = field_flags(node).anybits?(MULTILINE_FLAG)
91
+ group[:widgets].each do |widget|
92
+ widget.delete(:AS)
93
+ if value.empty?
94
+ widget.delete(:AP)
95
+ else
96
+ ap_ref = @appearance.build(node, widget, value, multiline: multiline)
97
+ widget[:AP] = { N: ap_ref } if ap_ref
98
+ end
99
+ end
100
+ true
101
+ end
102
+
103
+ # Checkboxes and radio groups carry per-state appearance streams, so
104
+ # filling only selects a state: /V on the field, /AS on each widget.
105
+ # The value may be a state name ("Yes"), or anything non-empty when the
106
+ # group has a single on state. Empty, "Off" or false-ish unchecks.
107
+ def fill_button(groups, value)
108
+ widgets = all_widgets(groups)
109
+ states = on_states(widgets)
110
+ # Match against the state names already present in the template
111
+ # rather than interning the untrusted value (avoids unbounded
112
+ # symbol creation from attacker-controlled field values).
113
+ named = states.find { |s| s.to_s == value }
114
+ state =
115
+ if ['', 'Off', 'false', 'no'].include?(value)
116
+ :Off
117
+ elsif named
118
+ named
119
+ elsif states.size == 1
120
+ states.first
121
+ else
122
+ return false
123
+ end
124
+
125
+ filled = false
126
+ groups.each do |group|
127
+ node = group[:node]
128
+ next if field_flags(node).anybits?(PUSHBUTTON_FLAG)
129
+
130
+ node[:V] = state
131
+ group[:widgets].each do |widget|
132
+ widget[:AS] = widget_states(widget).include?(state) ? state : :Off
133
+ end
134
+ filled = true
135
+ end
136
+ filled
137
+ end
138
+
139
+ def on_states(widgets)
140
+ widgets.flat_map { |widget| widget_states(widget) }.uniq - [:Off]
141
+ end
142
+
143
+ def widget_states(widget)
144
+ ap = @doc.deref(widget[:AP])
145
+ return [] unless ap.is_a?(Hash)
146
+
147
+ normal = @doc.deref(ap[:N])
148
+ normal.is_a?(Hash) && !normal.is_a?(PDF::Reader::Stream) ? normal.keys : []
149
+ end
150
+
151
+ # Guard against cyclic and shared /Kids graphs: a field node reachable
152
+ # more than once is expanded only the first time.
153
+ def visited?(ref, seen)
154
+ return false unless ref.is_a?(PDF::Reader::Reference)
155
+ return true if seen[ref.id]
156
+
157
+ seen[ref.id] = true
158
+ false
159
+ end
160
+
161
+ def collect_fields(kids, prefix, seen)
162
+ kids.each do |ref|
163
+ next if visited?(ref, seen)
164
+
165
+ node = @doc.deref(ref)
166
+ next unless node.is_a?(Hash)
167
+
168
+ name = [prefix, decode_name(node[:T])].compact.join('.')
169
+ child_refs = @doc.deref(node[:Kids]) || []
170
+ child_pairs = child_refs.map { |kid| [kid, @doc.deref(kid)] }
171
+ .select { |_kid, dict| dict.is_a?(Hash) }
172
+ subfields, widgets = child_pairs.partition { |_kid, dict| dict.key?(:T) }
173
+
174
+ if subfields.any?
175
+ # Recurse only into the named subfields; a stray bare widget mixed
176
+ # into the same /Kids must not register itself under this name.
177
+ collect_fields(subfields.map(&:first), name, seen)
178
+ else
179
+ widget_dicts = widgets.map(&:last)
180
+ widget_dicts = [node] if widget_dicts.empty? && node[:Rect]
181
+ @fields[name] << { node: node, widgets: widget_dicts } unless name.empty?
182
+ end
183
+ end
184
+ end
185
+
186
+ def decode_name(raw)
187
+ decode_text_string(@doc.deref(raw))
188
+ end
189
+
190
+ # PDF text strings are UTF-16BE (with BOM) or PDFDoc/Latin-ish; both are
191
+ # template-controlled, so decoding failures degrade instead of raising.
192
+ def decode_text_string(raw)
193
+ return raw unless raw.is_a?(String)
194
+
195
+ if raw.start_with?("\xFE\xFF".b)
196
+ raw.byteslice(2..).force_encoding('UTF-16BE')
197
+ .encode('UTF-8', invalid: :replace, undef: :replace)
198
+ else
199
+ raw.dup.force_encoding('UTF-8').scrub
200
+ end
201
+ end
202
+
203
+ def pdf_text_string(value)
204
+ return value.b if value.ascii_only?
205
+
206
+ "\xFE\xFF".b + value.encode('UTF-16BE').b
207
+ end
208
+
209
+ def flatten_page(page)
210
+ annots = (@doc.deref(page[:Annots]) || []).map { |a| [a, @doc.deref(a)] }
211
+ widgets, others = annots.partition { |_ref, dict| dict.is_a?(Hash) && dict[:Subtype] == :Widget }
212
+ return if widgets.empty?
213
+
214
+ stamps = []
215
+ widgets.each do |_ref, widget|
216
+ stamp = stamp_operations(page, widget)
217
+ stamps << stamp if stamp
218
+ end
219
+
220
+ unless stamps.empty?
221
+ wrap = ->(bytes) { @doc.add(StreamObject.new({}, bytes.b)) }
222
+ derefed = @doc.deref(page[:Contents])
223
+ contents = (derefed.is_a?(Array) ? derefed : [page[:Contents]]).compact
224
+ contents = contents.map { |stream| @doc.ref_for(stream) }
225
+ page[:Contents] = [wrap.call("q\n"), *contents, wrap.call("\nQ\n#{stamps.join("\n")}\n")]
226
+ end
227
+
228
+ remaining = others.map(&:first)
229
+ if remaining.empty?
230
+ page.delete(:Annots)
231
+ else
232
+ page[:Annots] = remaining
233
+ end
234
+ end
235
+
236
+ # Returns content-stream operations placing the widget's normal
237
+ # appearance onto the page, or nil when there is nothing to draw.
238
+ # Implements the appearance-box algorithm of PDF 32000 §12.5.5: the
239
+ # form's /Matrix is applied to its BBox, and the resulting extent is
240
+ # mapped onto the annotation rectangle.
241
+ def stamp_operations(page, widget)
242
+ return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
243
+
244
+ ap_ref = normal_appearance(widget)
245
+ xobject = @doc.deref(ap_ref)
246
+ dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
247
+ return nil unless dict.is_a?(Hash)
248
+
249
+ bbox = @doc.deref(dict[:BBox])
250
+ rect = @doc.deref(widget[:Rect])
251
+ return nil unless bbox.is_a?(Array) && rect.is_a?(Array)
252
+
253
+ # Appearance streams are form XObjects, but /Type and /Subtype are
254
+ # sometimes omitted; /Do requires them.
255
+ dict[:Type] ||= :XObject
256
+ dict[:Subtype] ||= :Form
257
+
258
+ llx, lly, urx, ury = normalize_box(rect)
259
+ bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
260
+ bw = bx1 - bx0
261
+ bh = by1 - by0
262
+ return nil if bw <= 0 || bh <= 0
263
+
264
+ sx = (urx - llx) / bw
265
+ sy = (ury - lly) / bh
266
+ name = register_xobject(page, ap_ref)
267
+ matrix = [sx, 0, 0, sy, llx - (bx0 * sx), lly - (by0 * sy)]
268
+ ops = matrix.map { |n| Serializer.format_number(n.to_f) }
269
+ "q #{ops.join(' ')} cm /#{name} Do Q"
270
+ end
271
+
272
+ # Bounding box of the BBox corners after the form's /Matrix (identity
273
+ # when absent or malformed).
274
+ def transformed_bbox(bbox, matrix)
275
+ x0, y0, x1, y1 = normalize_box(bbox)
276
+ return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
277
+ matrix.all?(Numeric)
278
+
279
+ a, b, c, d, e, f = matrix.map(&:to_f)
280
+ xs = []
281
+ ys = []
282
+ [[x0, y0], [x1, y0], [x0, y1], [x1, y1]].each do |x, y|
283
+ xs << ((a * x) + (c * y) + e)
284
+ ys << ((b * x) + (d * y) + f)
285
+ end
286
+ [xs.min, ys.min, xs.max, ys.max]
287
+ end
288
+
289
+ def normal_appearance(widget)
290
+ ap = @doc.deref(widget[:AP])
291
+ return nil unless ap.is_a?(Hash)
292
+
293
+ normal = ap[:N]
294
+ states = @doc.deref(normal)
295
+ if states.is_a?(Hash) && !states.is_a?(PDF::Reader::Stream)
296
+ # Pick the widget's current state; without /AS default to /Off
297
+ # (never an arbitrary "on" appearance for an unset checkbox).
298
+ state = @doc.deref(widget[:AS])
299
+ state = :Off unless state.is_a?(Symbol) && states.key?(state)
300
+ normal = states[state]
301
+ end
302
+ normal
303
+ end
304
+
305
+ def normalize_box(box)
306
+ xs = [box[0].to_f, box[2].to_f].sort
307
+ ys = [box[1].to_f, box[3].to_f].sort
308
+ [xs[0], ys[0], xs[1], ys[1]]
309
+ end
310
+
311
+ def register_xobject(page, ap_ref)
312
+ resources = @doc.deref(page[:Resources]) || @doc.inherited_value(page, :Resources)
313
+ resources = @doc.deref(resources) || {}
314
+ resources = resources.dup
315
+ xobjects = (@doc.deref(resources[:XObject]) || {}).dup
316
+
317
+ @stamp_counter = (@stamp_counter || 0) + 1
318
+ name = :"AcrofillAP#{@stamp_counter}"
319
+ xobjects[name] = @doc.ref_for(ap_ref)
320
+ resources[:XObject] = xobjects
321
+ page[:Resources] = resources
322
+ name
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Glyph widths (1/1000 em) for standard-14 fonts, ASCII 32..126,
4
+ # extracted from Adobe AFM metrics. Index = char code - 32.
5
+ module Acrofill
6
+ module Metrics
7
+ WIDTHS = {
8
+ 'Helvetica' => [278, 278, 355, 556, 556, 889, 667, 222, 333, 333, 389, 584, 278, 333, 278, 278, 556,
9
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 222, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584],
10
+ 'Helvetica-Bold' => [278, 333, 474, 556, 556, 889, 722, 278, 333, 333, 389, 584, 278, 333, 278, 278,
11
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 278, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584],
12
+ 'Courier' => [600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
13
+ 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600],
14
+ 'Times-Roman' => [250, 333, 408, 500, 500, 833, 778, 333, 333, 333, 500, 564, 250, 333, 250, 278, 500,
15
+ 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541]
16
+ }.freeze
17
+
18
+ DEFAULT_WIDTH = 556
19
+
20
+ def self.string_width(str, base_font, size)
21
+ table = WIDTHS[base_font] || WIDTHS['Helvetica']
22
+ units = str.each_char.sum do |ch|
23
+ code = ch.ord
24
+ code.between?(32, 126) ? table[code - 32] : DEFAULT_WIDTH
25
+ end
26
+ units * size / 1000.0
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acrofill
4
+ # Serializes Ruby values (as produced by pdf-reader) back into PDF syntax.
5
+ # References are renumbered through the map supplied by the Writer.
6
+ class Serializer
7
+ # PDF "regular characters": printable ASCII minus whitespace, the
8
+ # delimiters ( ) < > [ ] { } / % and the name-escape character #.
9
+ REGULAR_NAME_CHAR = %r{[!-~&&[^()<>\[\]{}/%#]]}
10
+ REGULAR_NAME = /\A#{REGULAR_NAME_CHAR.source}+\z/
11
+ MAX_DEPTH = 200
12
+
13
+ def self.format_number(num)
14
+ # PDF has no syntax for infinity/NaN, and #to_i on them raises;
15
+ # a non-finite number can only come from malformed input, so clamp.
16
+ return '0' unless num.finite?
17
+ return num.to_i.to_s if num == num.to_i
18
+
19
+ format('%.5f', num).sub(/\.?0+\z/, '')
20
+ end
21
+
22
+ def initialize(oid_map)
23
+ @oid_map = oid_map
24
+ end
25
+
26
+ def serialize(obj, depth = 0)
27
+ # Bound recursion: a pathologically nested array/dict from malformed
28
+ # input would otherwise overflow the stack.
29
+ raise Error, 'object nesting too deep' if depth > MAX_DEPTH
30
+
31
+ case obj
32
+ when PDF::Reader::Reference
33
+ oid = @oid_map[obj.id]
34
+ oid ? "#{oid} 0 R" : 'null'
35
+ when Hash then serialize_dict(obj, depth)
36
+ when Array then "[#{obj.map { |el| serialize(el, depth + 1) }.join(' ')}]"
37
+ when Symbol then serialize_name(obj)
38
+ when String then serialize_string(obj)
39
+ when Integer then obj.to_s
40
+ when Float then self.class.format_number(obj)
41
+ when TrueClass then 'true'
42
+ when FalseClass then 'false'
43
+ when NilClass then 'null'
44
+ else
45
+ raise Error, "cannot serialize #{obj.class}"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def serialize_dict(dict, depth = 0)
52
+ inner = dict.map { |k, v| "#{serialize_name(k)} #{serialize(v, depth + 1)}" }.join(' ')
53
+ "<< #{inner} >>"
54
+ end
55
+
56
+ def serialize_name(sym)
57
+ str = sym.to_s
58
+ return "/#{str}" if REGULAR_NAME.match?(str) # fast path: no escaping
59
+
60
+ encoded = str.bytes.map do |byte|
61
+ char = byte.chr
62
+ REGULAR_NAME_CHAR.match?(char) ? char : format('#%02X', byte)
63
+ end.join
64
+ "/#{encoded}"
65
+ end
66
+
67
+ # Hex strings sidestep every escaping concern for binary payloads
68
+ # (/ID entries, UTF-16BE values, Info metadata).
69
+ def serialize_string(str)
70
+ "<#{str.unpack1('H*').upcase}>"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acrofill
4
+ # A pre-parsed, reusable template. Parsing is the dominant cost of a
5
+ # fill (tokenizing and inflating the whole file); Template pays it once
6
+ # and restores a pristine object graph from a Marshal snapshot for each
7
+ # subsequent fill — roughly 50x faster than re-parsing per fill.
8
+ #
9
+ # template = Acrofill::Template.new('claim_form.pdf')
10
+ # template.fill_form('a.pdf', { 'Name' => 'Jane' }, flatten: true)
11
+ # template.fill_form('b.pdf', { 'Name' => 'John' }, flatten: true)
12
+ #
13
+ # Instances are cheap to keep around (the snapshot is a single string,
14
+ # comparable to the file size) and safe to share across threads: every
15
+ # fill works on its own restored copy.
16
+ class Template
17
+ def initialize(path)
18
+ doc = Document.new(path)
19
+ @snapshot = doc.snapshot
20
+ end
21
+
22
+ def fill_form(destination, data = {}, options = {})
23
+ Filler.new.apply(Document.restore(@snapshot), destination, data, options)
24
+ end
25
+
26
+ # Field metadata without touching disk again.
27
+ def fields
28
+ Form.new(Document.restore(@snapshot)).fields
29
+ end
30
+
31
+ def field_names
32
+ fields.map(&:name)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acrofill
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/md5'
4
+
5
+ module Acrofill
6
+ # Writes the document back out as a fresh PDF: only objects reachable from
7
+ # the trailer are emitted, renumbered contiguously, with a classic xref
8
+ # table. Existing streams are copied verbatim with their original filters.
9
+ class Writer
10
+ HEADER = "%PDF-1.6\n%\xE2\xE3\xCF\xD3\n".b
11
+
12
+ def initialize(document)
13
+ @doc = document
14
+ end
15
+
16
+ def render
17
+ order = reachable_oids
18
+ oid_map = order.each_with_index.to_h { |oid, idx| [oid, idx + 1] }
19
+ serializer = Serializer.new(oid_map)
20
+
21
+ out = HEADER.dup
22
+ offsets = []
23
+ order.each do |oid|
24
+ offsets << out.bytesize
25
+ out << "#{oid_map[oid]} 0 obj\n"
26
+ out << serialize_object(@doc.objects[oid], serializer)
27
+ out << "\nendobj\n"
28
+ end
29
+
30
+ xref_offset = out.bytesize
31
+ out << "xref\n0 #{order.size + 1}\n0000000000 65535 f \n"
32
+ offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
33
+
34
+ trailer = {
35
+ Size: order.size + 1,
36
+ Root: @doc.trailer[:Root],
37
+ Info: @doc.trailer[:Info],
38
+ ID: trailer_id(out)
39
+ }.compact
40
+ out << "trailer\n#{serializer.serialize(trailer)}\n"
41
+ out << "startxref\n#{xref_offset}\n%%EOF\n"
42
+ out
43
+ end
44
+
45
+ private
46
+
47
+ # Preserves the original /ID pair, or derives one from the serialized
48
+ # body so identical inputs always produce identical output bytes.
49
+ def trailer_id(body)
50
+ id = @doc.trailer[:ID]
51
+ return id if id.is_a?(Array) && id.size == 2 && id.all?(String)
52
+
53
+ digest = Digest::MD5.digest(body)
54
+ [digest, digest]
55
+ end
56
+
57
+ def serialize_object(obj, serializer)
58
+ dict, raw =
59
+ case obj
60
+ when PDF::Reader::Stream then [obj.hash, obj.data]
61
+ when StreamObject then [obj.dict, obj.raw]
62
+ else return serializer.serialize(obj)
63
+ end
64
+
65
+ dict = dict.merge(Length: raw.bytesize)
66
+ "#{serializer.serialize(dict)}\nstream\n".b + raw.b + "\nendstream".b
67
+ end
68
+
69
+ # Breadth-first walk from the trailer; keeps original ordering stable
70
+ # and drops everything orphaned (old xref streams, removed form fields).
71
+ def reachable_oids
72
+ queue = [@doc.trailer[:Root], @doc.trailer[:Info]].compact
73
+ seen = {}
74
+ order = []
75
+
76
+ until queue.empty?
77
+ current = queue.shift
78
+ case current
79
+ when PDF::Reader::Reference
80
+ next if seen[current.id]
81
+
82
+ seen[current.id] = true
83
+ obj = @doc.objects[current.id]
84
+ next if obj.nil?
85
+
86
+ order << current.id
87
+ queue << obj
88
+ when Hash
89
+ queue.concat(current.values)
90
+ when Array
91
+ queue.concat(current)
92
+ when PDF::Reader::Stream
93
+ queue << current.hash
94
+ when StreamObject
95
+ queue << current.dict
96
+ end
97
+ end
98
+ order
99
+ end
100
+ end
101
+ end
data/lib/acrofill.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'acrofill/version'
4
+ require_relative 'acrofill/document'
5
+ require_relative 'acrofill/metrics'
6
+ require_relative 'acrofill/serializer'
7
+ require_relative 'acrofill/writer'
8
+ require_relative 'acrofill/appearance'
9
+ require_relative 'acrofill/form'
10
+ require_relative 'acrofill/filler'
11
+ require_relative 'acrofill/template'
12
+
13
+ module Acrofill
14
+ class Error < StandardError; end
15
+
16
+ # Mirrors the PdfForms.new(pdftk_path) constructor shape.
17
+ def self.new(*args)
18
+ Filler.new(*args)
19
+ end
20
+
21
+ def self.fill_form(template, destination, data = {}, options = {})
22
+ Filler.new.fill_form(template, destination, data, options)
23
+ end
24
+
25
+ def self.fields(template)
26
+ Filler.new.fields(template)
27
+ end
28
+
29
+ def self.field_names(template)
30
+ Filler.new.field_names(template)
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acrofill
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - stiig
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pdf-reader
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.4'
27
+ description: Fills PDF form fields (text, checkbox, radio, choice) in-process and
28
+ optionally flattens the result into static page content. An MIT-licensed, no-dependencies-on-binaries
29
+ alternative to the pdftk fill_form pipeline.
30
+ email:
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE.txt
36
+ - README.md
37
+ - lib/acrofill.rb
38
+ - lib/acrofill/appearance.rb
39
+ - lib/acrofill/document.rb
40
+ - lib/acrofill/filler.rb
41
+ - lib/acrofill/form.rb
42
+ - lib/acrofill/metrics.rb
43
+ - lib/acrofill/serializer.rb
44
+ - lib/acrofill/template.rb
45
+ - lib/acrofill/version.rb
46
+ - lib/acrofill/writer.rb
47
+ homepage: https://github.com/stiig/acrofill
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/stiig/acrofill
52
+ source_code_uri: https://github.com/stiig/acrofill
53
+ rubygems_mfa_required: 'true'
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.1.6
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Pure-Ruby PDF form (AcroForm) filling and flattening
73
+ test_files: []