acrofill 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28d2db0f5f96d10602a2c8dd994a9957665df1426cffdbc045b3860c675e504e
4
- data.tar.gz: c683b238b9ff31d182ad316712e3982c3ffa6dd38a92fa6547efca3e784d02c4
3
+ metadata.gz: 33e5bcd52afaae06f765c6e4342bdb98d051ec8ece25a41616e8d38c16de081f
4
+ data.tar.gz: f1f4b9e8ec240585c531bc9e4935072b3df7d739c0a872e3b3a151a54cd3cbb5
5
5
  SHA512:
6
- metadata.gz: fb49f9bb0fe9efd45665d92c09c845a4156695b457bd9a3fddc13e7496a1221a009732147958f2a753130ebd5dc2f78b569b0ad117bf10ffa3262ba85bd3464e
7
- data.tar.gz: 492ac4103cfa6fb500c70a648a94f479cf7411eb2293d5b7fc29ab7753e90bcd3a52f244facd94732e237f410a0038a9ca2a0ed2e7321a6ae4cc907744a4c265
6
+ metadata.gz: ac9f95c2a5d7fe3a7bd939ec30b84f676c25875217b46ca2c55bbb924625077aae6c2e4bd567cc89a13577b6fd692010b73e3565f5bb30deda01d58dff345baf
7
+ data.tar.gz: d80186517e1b68256c65b414412d9b9fe897112a1acd08e7cf2917ddf1a3d9510c8eb91618018004a83bfd8975808bdb22e018d393c92eb8cf2e25e82ad62d8a
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Fixed
11
+
12
+ - Parse failures from lazily materialized objects and parser stack
13
+ overflows on deeply nested objects now raise `Acrofill::Error` instead
14
+ of leaking `PDF::Reader::MalformedPDFError` or `SystemStackError`.
15
+ - The page-tree and field-tree walks are iterative, so deep linear
16
+ `/Pages` or `/Kids` chains no longer overflow the stack.
17
+ - Indirect references are dereferenced where they were previously assumed
18
+ direct: field `/V` in metadata, `/Rect` and `/BBox` corner values,
19
+ `/Matrix` elements, and the `/Q` alignment value.
20
+ - A checkbox/radio whose on state is literally named `no` or `false` can
21
+ now be checked: an exact state-name match takes precedence over the
22
+ false-ish uncheck heuristic.
23
+
24
+ ## [0.1.0] - 2026-07-24
25
+
26
+ ### Added
27
+
28
+ - Initial release: pure-Ruby AcroForm filling and flattening.
29
+ - Text fields (`/Tx`): hierarchical names, inherited `/DA`, `/Q` alignment,
30
+ auto font size, shrink-to-fit, multiline word wrapping.
31
+ - Checkboxes and radio groups (`/Btn`) via `/V` + `/AS` state selection.
32
+ - Choice fields (`/Ch`).
33
+ - Flattening of visible widget appearances into page content.
34
+ - `Acrofill::Template` for parse-once, fill-many workloads.
35
+ - `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
36
+ `field_names`).
37
+
38
+ [Unreleased]: https://github.com/stiig/acrofill/compare/v0.1.0...HEAD
39
+ [0.1.0]: https://github.com/stiig/acrofill/releases/tag/v0.1.0
data/README.md CHANGED
@@ -137,9 +137,10 @@ Acrofill is pure Ruby with no `eval`/`system`/native calls in its hot
137
137
  path, so the worst an input can do is cause an exception — never memory
138
138
  corruption or code execution. Specifically it defends against:
139
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
140
+ - **Cyclic, exponentially-shared and deeply nested object graphs** — the
141
+ page tree and field tree walkers are iterative and carry visited-sets,
142
+ so a `/Pages` or `/Kids` cycle, a "billion laughs" DAG, or a
143
+ thousands-deep linear chain terminates in O(objects) instead of hanging
143
144
  or overflowing the stack.
144
145
  - **Content-stream injection via `/DA`** — the template's default
145
146
  appearance string is sanitized: the font name must be a regular PDF
@@ -150,8 +151,9 @@ corruption or code execution. Specifically it defends against:
150
151
  mistyped dictionaries are clamped or rejected rather than crashing the
151
152
  writer.
152
153
  - **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.
154
+ at the parse boundary surfaces as `Acrofill::Error` (including lazy
155
+ per-object parse errors and parser stack overflow on pathologically
156
+ nested objects), so callers only ever rescue one error type.
155
157
 
156
158
  ## Feature matrix
157
159
 
@@ -27,7 +27,7 @@ module Acrofill
27
27
 
28
28
  font_name, size, color_ops = parse_da(field_node)
29
29
  base_font = base_font_for(font_name)
30
- align = @doc.inherited_value(field_node, :Q) || @acroform[:Q] || 0
30
+ align = alignment(field_node)
31
31
 
32
32
  body =
33
33
  if multiline
@@ -38,7 +38,9 @@ module Acrofill
38
38
  text = printable_text(value)
39
39
  size = [height * 0.66, 12.0].min if size.zero?
40
40
  size = shrink_to_fit(text, base_font, size, width)
41
- ty = [(height - (size * (ASCENT - DESCENT))) / 2.0, size * DESCENT].max
41
+ # Vertically center the ascent box, matching pdftk's baseline
42
+ # placement exactly: ty = (h - ascent*size) / 2.
43
+ ty = [(height - (size * ASCENT)) / 2.0, size * DESCENT].max
42
44
  "#{fmt(line_x(text, base_font, size, width, align))} #{fmt(ty)} Td\n" \
43
45
  "(#{escape_literal(text)}) Tj\n"
44
46
  end
@@ -65,6 +67,12 @@ module Acrofill
65
67
  Serializer.format_number(num.to_f)
66
68
  end
67
69
 
70
+ # /Q (0 left, 1 center, 2 right), inheritable and possibly indirect.
71
+ def alignment(field_node)
72
+ align = @doc.deref(@doc.inherited_value(field_node, :Q) || @acroform[:Q])
73
+ align.is_a?(Integer) ? align : 0
74
+ end
75
+
68
76
  def line_x(text, base_font, size, width, align)
69
77
  text_width = Metrics.string_width(text, base_font, size)
70
78
  case align
@@ -117,12 +125,17 @@ module Acrofill
117
125
  lines
118
126
  end
119
127
 
128
+ # Array entries may legally be indirect objects, so each corner is
129
+ # dereferenced; a rect that is not four numbers is unusable.
120
130
  def normalized_rect(rect)
121
131
  rect = @doc.deref(rect)
122
132
  return nil unless rect.is_a?(Array) && rect.size == 4
123
133
 
124
- xs = [rect[0].to_f, rect[2].to_f].sort
125
- ys = [rect[1].to_f, rect[3].to_f].sort
134
+ nums = rect.map { |n| @doc.deref(n) }
135
+ return nil unless nums.all?(Numeric)
136
+
137
+ xs = [nums[0].to_f, nums[2].to_f].sort
138
+ ys = [nums[1].to_f, nums[3].to_f].sort
126
139
  [xs[0], ys[0], xs[1], ys[1]]
127
140
  end
128
141
 
@@ -14,14 +14,18 @@ module Acrofill
14
14
  attr_reader :objects, :trailer
15
15
 
16
16
  def initialize(path)
17
- hash = load_object_hash(path)
17
+ hash = parse_boundary { PDF::Reader::ObjectHash.new(path) }
18
18
  raise Error, 'encrypted PDFs are not supported' if hash.trailer[:Encrypt]
19
19
 
20
20
  @objects = {}
21
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
22
+ # ObjectHash parses object bodies lazily, so the iteration that
23
+ # materializes them must also stay inside the parse boundary.
24
+ parse_boundary do
25
+ hash.each do |ref, obj|
26
+ @objects[ref.id] = obj
27
+ @max_oid = ref.id if ref.id > @max_oid
28
+ end
25
29
  end
26
30
  @trailer = hash.trailer.slice(:Root, :Info, :ID)
27
31
  raise Error, 'PDF has no document catalog' unless @trailer[:Root]
@@ -56,11 +60,13 @@ module Acrofill
56
60
  # The parse boundary for untrusted input: pdf-reader (and the decryptors
57
61
  # it calls) can raise a wide assortment of errors on crafted files, so
58
62
  # everything is normalized to a single Acrofill::Error type.
59
- def load_object_hash(path)
60
- PDF::Reader::ObjectHash.new(path)
63
+ # SystemStackError is not a StandardError but must be caught too: the
64
+ # recursive-descent parser overflows on deeply nested objects.
65
+ def parse_boundary
66
+ yield
61
67
  rescue PDF::Reader::EncryptedPDFError => e
62
68
  raise Error, "encrypted PDFs are not supported (#{e.message})"
63
- rescue StandardError => e
69
+ rescue StandardError, SystemStackError => e
64
70
  raise Error, "could not parse PDF (#{e.class}: #{e.message})"
65
71
  end
66
72
 
@@ -94,23 +100,31 @@ module Acrofill
94
100
  deref(@trailer[:Root])
95
101
  end
96
102
 
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
103
+ # Depth-first, document-order walk over the /Pages tree. Iterative with
104
+ # an explicit stack (a deep linear chain must not overflow the Ruby
105
+ # stack) and a visited set (a cyclic or shared billion-laughs tree must
106
+ # not hang or amplify).
107
+ def each_page
108
+ stack = [root[:Pages]]
109
+ seen = {}
110
+ until stack.empty?
111
+ node = stack.pop
112
+ dict = deref(node)
113
+ next unless dict.is_a?(Hash)
114
+
115
+ if node.is_a?(PDF::Reader::Reference)
116
+ next if seen[node.id]
117
+
118
+ seen[node.id] = true
119
+ end
120
+
121
+ case dict[:Type]
122
+ when :Pages
123
+ kids = deref(dict[:Kids])
124
+ stack.concat(kids.reverse) if kids.is_a?(Array)
125
+ when :Page
126
+ yield dict
127
+ end
114
128
  end
115
129
  end
116
130
 
data/lib/acrofill/form.rb CHANGED
@@ -21,7 +21,8 @@ module Acrofill
21
21
  # single logical field, e.g. "SSN" repeated on every page) — filling
22
22
  # must update all of them.
23
23
  @fields = Hash.new { |h, k| h[k] = [] }
24
- collect_fields(doc.deref(@acroform[:Fields]) || [], nil, {})
24
+ roots = doc.deref(@acroform[:Fields])
25
+ collect_fields(roots.is_a?(Array) ? roots : [])
25
26
  end
26
27
 
27
28
  # Metadata for every logical field, in document order.
@@ -31,7 +32,7 @@ module Acrofill
31
32
  Field.new(
32
33
  name: name,
33
34
  type: type,
34
- value: decode_text_string(groups.first[:node][:V]),
35
+ value: decode_text_string(@doc.deref(groups.first[:node][:V])),
35
36
  states: type == :Btn ? on_states(all_widgets(groups)) : nil
36
37
  )
37
38
  end
@@ -103,7 +104,9 @@ module Acrofill
103
104
  # Checkboxes and radio groups carry per-state appearance streams, so
104
105
  # filling only selects a state: /V on the field, /AS on each widget.
105
106
  # 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
+ # group has a single on state. Empty, "Off" or false-ish unchecks
108
+ # unless the value exactly names an on state (a checkbox whose state is
109
+ # literally called "no" must remain checkable).
107
110
  def fill_button(groups, value)
108
111
  widgets = all_widgets(groups)
109
112
  states = on_states(widgets)
@@ -112,10 +115,10 @@ module Acrofill
112
115
  # symbol creation from attacker-controlled field values).
113
116
  named = states.find { |s| s.to_s == value }
114
117
  state =
115
- if ['', 'Off', 'false', 'no'].include?(value)
116
- :Off
117
- elsif named
118
+ if named
118
119
  named
120
+ elsif ['', 'Off', 'false', 'no'].include?(value)
121
+ :Off
119
122
  elsif states.size == 1
120
123
  states.first
121
124
  else
@@ -158,23 +161,27 @@ module Acrofill
158
161
  false
159
162
  end
160
163
 
161
- def collect_fields(kids, prefix, seen)
162
- kids.each do |ref|
164
+ # Depth-first, document-order walk over the field tree. Iterative with
165
+ # an explicit stack of [ref, prefix] pairs (a deep linear /Kids chain
166
+ # must not overflow the Ruby stack), with a visited set against cycles
167
+ # and shared nodes.
168
+ def collect_fields(roots)
169
+ seen = {}
170
+ stack = roots.reverse.map { |ref| [ref, nil] }
171
+ until stack.empty?
172
+ ref, prefix = stack.pop
163
173
  next if visited?(ref, seen)
164
174
 
165
175
  node = @doc.deref(ref)
166
176
  next unless node.is_a?(Hash)
167
177
 
168
178
  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) }
179
+ subfields, widgets = partition_kids(node)
173
180
 
174
181
  if subfields.any?
175
- # Recurse only into the named subfields; a stray bare widget mixed
182
+ # Descend only into the named subfields; a stray bare widget mixed
176
183
  # into the same /Kids must not register itself under this name.
177
- collect_fields(subfields.map(&:first), name, seen)
184
+ stack.concat(subfields.map { |kid, _dict| [kid, name] }.reverse)
178
185
  else
179
186
  widget_dicts = widgets.map(&:last)
180
187
  widget_dicts = [node] if widget_dicts.empty? && node[:Rect]
@@ -183,6 +190,16 @@ module Acrofill
183
190
  end
184
191
  end
185
192
 
193
+ # Splits a node's /Kids into named subfields and bare widgets, dropping
194
+ # anything that does not deref to a dictionary.
195
+ def partition_kids(node)
196
+ child_refs = @doc.deref(node[:Kids])
197
+ child_refs = [] unless child_refs.is_a?(Array)
198
+ child_refs.map { |kid| [kid, @doc.deref(kid)] }
199
+ .select { |_kid, dict| dict.is_a?(Hash) }
200
+ .partition { |_kid, dict| dict.key?(:T) }
201
+ end
202
+
186
203
  def decode_name(raw)
187
204
  decode_text_string(@doc.deref(raw))
188
205
  end
@@ -207,7 +224,9 @@ module Acrofill
207
224
  end
208
225
 
209
226
  def flatten_page(page)
210
- annots = (@doc.deref(page[:Annots]) || []).map { |a| [a, @doc.deref(a)] }
227
+ annot_refs = @doc.deref(page[:Annots])
228
+ annot_refs = [] unless annot_refs.is_a?(Array)
229
+ annots = annot_refs.map { |a| [a, @doc.deref(a)] }
211
230
  widgets, others = annots.partition { |_ref, dict| dict.is_a?(Hash) && dict[:Subtype] == :Widget }
212
231
  return if widgets.empty?
213
232
 
@@ -246,16 +265,16 @@ module Acrofill
246
265
  dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
247
266
  return nil unless dict.is_a?(Hash)
248
267
 
249
- bbox = @doc.deref(dict[:BBox])
250
- rect = @doc.deref(widget[:Rect])
251
- return nil unless bbox.is_a?(Array) && rect.is_a?(Array)
268
+ bbox = normalize_box(@doc.deref(dict[:BBox]))
269
+ rect = normalize_box(@doc.deref(widget[:Rect]))
270
+ return nil unless bbox && rect
252
271
 
253
272
  # Appearance streams are form XObjects, but /Type and /Subtype are
254
273
  # sometimes omitted; /Do requires them.
255
274
  dict[:Type] ||= :XObject
256
275
  dict[:Subtype] ||= :Form
257
276
 
258
- llx, lly, urx, ury = normalize_box(rect)
277
+ llx, lly, urx, ury = rect
259
278
  bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
260
279
  bw = bx1 - bx0
261
280
  bh = by1 - by0
@@ -269,10 +288,11 @@ module Acrofill
269
288
  "q #{ops.join(' ')} cm /#{name} Do Q"
270
289
  end
271
290
 
272
- # Bounding box of the BBox corners after the form's /Matrix (identity
273
- # when absent or malformed).
291
+ # Bounding box of the (already normalized) BBox corners after the
292
+ # form's /Matrix (identity when absent or malformed).
274
293
  def transformed_bbox(bbox, matrix)
275
- x0, y0, x1, y1 = normalize_box(bbox)
294
+ x0, y0, x1, y1 = bbox
295
+ matrix = matrix.map { |m| @doc.deref(m) } if matrix.is_a?(Array)
276
296
  return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
277
297
  matrix.all?(Numeric)
278
298
 
@@ -302,9 +322,17 @@ module Acrofill
302
322
  normal
303
323
  end
304
324
 
325
+ # Derefs each element (array entries may legally be indirect objects)
326
+ # and returns [llx, lly, urx, ury], or nil when the box is not four
327
+ # numbers.
305
328
  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
329
+ return nil unless box.is_a?(Array) && box.size == 4
330
+
331
+ nums = box.map { |n| @doc.deref(n) }
332
+ return nil unless nums.all?(Numeric)
333
+
334
+ xs = [nums[0].to_f, nums[2].to_f].sort
335
+ ys = [nums[1].to_f, nums[3].to_f].sort
308
336
  [xs[0], ys[0], xs[1], ys[1]]
309
337
  end
310
338
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acrofill
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/acrofill.rb CHANGED
@@ -14,8 +14,8 @@ module Acrofill
14
14
  class Error < StandardError; end
15
15
 
16
16
  # Mirrors the PdfForms.new(pdftk_path) constructor shape.
17
- def self.new(*args)
18
- Filler.new(*args)
17
+ def self.new(*)
18
+ Filler.new(*)
19
19
  end
20
20
 
21
21
  def self.fill_form(template, destination, data = {}, options = {})
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acrofill
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - stiig
@@ -32,6 +32,7 @@ executables: []
32
32
  extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
+ - CHANGELOG.md
35
36
  - LICENSE.txt
36
37
  - README.md
37
38
  - lib/acrofill.rb
@@ -50,6 +51,8 @@ licenses:
50
51
  metadata:
51
52
  homepage_uri: https://github.com/stiig/acrofill
52
53
  source_code_uri: https://github.com/stiig/acrofill
54
+ changelog_uri: https://github.com/stiig/acrofill/blob/main/CHANGELOG.md
55
+ bug_tracker_uri: https://github.com/stiig/acrofill/issues
53
56
  rubygems_mfa_required: 'true'
54
57
  post_install_message:
55
58
  rdoc_options: []
@@ -59,7 +62,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
59
62
  requirements:
60
63
  - - ">="
61
64
  - !ruby/object:Gem::Version
62
- version: '3.0'
65
+ version: '3.2'
63
66
  required_rubygems_version: !ruby/object:Gem::Requirement
64
67
  requirements:
65
68
  - - ">="