acrofill 0.4.0 → 0.4.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: 633b173570db39e5b16714fd23e558a6832ed36f38a549802eb9dcda64967854
4
- data.tar.gz: 26d94b282728f297c7c61d51ed817749d8435ecdd6dd4962a11942ac0af30fe9
3
+ metadata.gz: 2004f19b67792da81c96fb674f6659f9a8da74929334c73a7a46705337cf3545
4
+ data.tar.gz: 427e766a9775dfdbc5d6651c993418b0bf6d29694bc9462cae69aa719e242532
5
5
  SHA512:
6
- metadata.gz: c5aad0c14c51e0d9e28a2c4c09b03d840b01c91ef83908ea0d31674f12ff0e4da8c33e7918600ba8ef409313a4faf0e8cfad30b325b3e23fa5c2786643fcdc97
7
- data.tar.gz: 18be3697b53f7fe00d6d815a8cd7e0755ca3eef63eccc02e4f9dba181c4a14df648b05289e10ba9bf92b4e0ff62032ff9b1f42a6464a4ed3b75cd2c09b693261
6
+ metadata.gz: c8b11295461d44e3ce2fe61ecd3b066f70fe60f69fd84ad9d38b7606eae4cc08d059ffd921e65cc75cc768b454af37e822d9f7b823d03ba6e768200aa0e3457e
7
+ data.tar.gz: fc4eae29263ce69cd1ac90c015b02f2b321fe6e8e3df8c5091b6888024fcc084a54919b158edfd5a7209ab426b697e37da908409a48ca81dfa9b5b00b477b9c1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-07-27
11
+
12
+ ### Fixed
13
+
14
+ - A `/Rect` or `/BBox` whose corners are not four finite numbers, or whose
15
+ width or height overflows, is now refused as unusable geometry (no
16
+ appearance, `/AP` dropped) rather than laid out against. Subtracting two
17
+ infinite corners produced a NaN extent that every `<= 0` guard silently
18
+ passed, so the widget either raised `ArgumentError` out of
19
+ `Acrofill.fill_form` or was drawn into a box that serialized to
20
+ `[0 0 0 0]` — blank, while `/V` already held the new value.
21
+ - A non-finite `/Matrix` on an appearance stream is treated as the identity,
22
+ the way a missing or otherwise malformed one already was. Flattening a
23
+ widget whose form XObject carried one raised `ArgumentError`.
24
+ - A non-finite point size in a `/DA` string degrades to "fit the box", the
25
+ way a `/DA` with no size already does, rather than raising. A finite but
26
+ enormous one no longer overflows while being scaled down to fit.
27
+ - `Acrofill.new(path, flatten: true)` now applies that option to every
28
+ `#fill_form` call, matching the `PdfForms.new` shape it mirrors. Options
29
+ given to the constructor were accepted and discarded, so the drop-in path
30
+ shipped interactive documents where flattened ones were asked for.
31
+ - `Template#fill_form` refuses a destination equal to its own source instead
32
+ of overwriting the template in place. The argument order is the reverse of
33
+ `Filler#fill_form`, and since `Template` never re-reads the file the
34
+ corruption stayed invisible for the life of the process.
35
+ - A widget whose `/Rect` is inherited from its parent field is now stamped
36
+ when flattening. The value was filled, then dropped from the output.
37
+ - Fields sharing one fully-qualified name but not one `/FT` are each filled
38
+ through their own type's path. A checkbox sharing a name with a text field
39
+ had its `/AP` state dictionary overwritten with a text appearance.
40
+ - Checkbox values are matched case-insensitively, so `"off"`, `"No"` and `0`
41
+ uncheck rather than falling through and ticking the box. A state the
42
+ template itself names still wins.
43
+ - Field names that are neither UTF-16BE nor UTF-8 are decoded rather than
44
+ scrubbed. Every accented byte became U+FFFD, renaming the field to
45
+ something no caller could pass back in.
46
+ - Glyph widths are measured in the font's own code space, so a `/Encoding`
47
+ that moves a glyph onto a code below 32 measures that glyph instead of
48
+ charging the average width. Multiline wrapping likewise runs before
49
+ encoding, so a font that moves the space glyph off code 32 still wraps.
50
+ - Font names are no longer read as width classes by substring alone:
51
+ `MonotypeCorsiva` is proportional, `Blackadder-ITC` is not bold, while
52
+ `Arial-Black` and `CourierNewPSMT` still classify as before.
53
+ - `/DA` colour operands on opposite sides of the `Tf` triple are no longer
54
+ joined, which could fuse tokens that were never adjacent into a valid
55
+ looking operator and repaint the value.
56
+ - A negative `/DA` size is treated as "fit the box", like zero, instead of
57
+ rendering at the 2pt floor on single-line fields but 12pt on multiline.
58
+ - Flattening drops an annotation reference that resolves to nothing (it
59
+ serialized as a bare `null` in `/Annots`), honours an indirect `/Subtype`,
60
+ refuses a degenerate `/Rect` that would produce a singular matrix, and
61
+ survives a `/Matrix` whose finite entries multiply out of range.
62
+ - A `/DR` font entry that is not a font dictionary no longer reaches the
63
+ generated appearance's `/Resources`, where it left `/Tf` pointing at a
64
+ non-font object.
65
+ - A page's `/Resources` reached through `/Parent` is copied before the stamp
66
+ `/XObject` is added, so pages sharing that node are left alone.
67
+ - `File.binwrite` failures surface as `Acrofill::Error`, the error the public
68
+ API documents, rather than a bare `Errno`.
69
+
70
+ ### Changed
71
+
72
+ - `Acrofill::Metrics` exposes `.remap_for` (the `/Differences` code table)
73
+ and `Metrics::Font#width_of`; `Acrofill::Document` exposes
74
+ `#normalized_box`. These replace private copies that `Fonts`, `Appearance`
75
+ and `Flattener` each carried.
76
+ - `Metrics.string_width` takes a 256-entry code-space table (from the new
77
+ `Metrics.build_font`) rather than a `/Widths`-shaped one indexed from code
78
+ 32. Passing a `BaseFont` name still works.
79
+ - The release workflow runs the specs and RuboCop before publishing; a tag
80
+ push matched no trigger in the CI workflow, so the gem could be pushed
81
+ with no test run behind it.
82
+
10
83
  ## [0.4.0] - 2026-07-27
11
84
 
12
85
  ### Added
@@ -167,7 +240,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
167
240
  - `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
168
241
  `field_names`).
169
242
 
170
- [Unreleased]: https://github.com/stiig/acrofill/compare/v0.4.0...HEAD
243
+ [Unreleased]: https://github.com/stiig/acrofill/compare/v0.4.1...HEAD
244
+ [0.4.1]: https://github.com/stiig/acrofill/compare/v0.4.0...v0.4.1
171
245
  [0.4.0]: https://github.com/stiig/acrofill/compare/v0.3.0...v0.4.0
172
246
  [0.3.0]: https://github.com/stiig/acrofill/compare/v0.2.0...v0.3.0
173
247
  [0.2.0]: https://github.com/stiig/acrofill/compare/v0.1.2...v0.2.0
@@ -7,11 +7,16 @@ module Acrofill
7
7
  PADDING = 2.0
8
8
  # pdftk offsets the first multiline row by 1pt from the box top.
9
9
  TOP_OFFSET = 1.0
10
+ DEFAULT_SIZE = 12.0
11
+ MIN_SIZE = 2.0
12
+ MAX_SIZE = 144.0
10
13
 
11
14
  # The drawable area of one widget: its size and the field's /Q.
12
15
  Box = Struct.new(:width, :height, :align)
13
16
  # Colour-setting operators allowed in a /DA string, and their operand counts.
14
17
  COLOR_OP_ARITY = { 'g' => 1, 'rg' => 3, 'k' => 4 }.freeze
18
+ # A plain PDF real; exponent notation is not valid PDF number syntax.
19
+ NUMERIC_OPERAND = /\A-?\d*\.?\d+\z/
15
20
 
16
21
  def initialize(doc, acroform)
17
22
  @doc = doc
@@ -22,7 +27,7 @@ module Acrofill
22
27
  # Returns a Reference to the new appearance XObject, or nil when the
23
28
  # widget geometry is unusable.
24
29
  def build(field_node, widget, value, multiline: false, comb: nil)
25
- rect = normalized_rect(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
30
+ rect = @doc.normalized_box(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
26
31
  return nil unless rect
27
32
 
28
33
  box = Box.new(rect[2] - rect[0], rect[3] - rect[1], alignment(field_node))
@@ -45,23 +50,39 @@ module Acrofill
45
50
  # The operators drawing +value+, and the point size they were laid out
46
51
  # at — auto-sizing and shrink-to-fit both adjust the /DA size.
47
52
  def draw(value, font, size, box, multiline:, comb:)
53
+ # Multiline wraps the raw value, so it encodes paragraph by paragraph.
48
54
  if multiline
49
- size = (size <= 0 ? 12.0 : size).clamp(2.0, 144.0)
50
- [size, multiline_body(value, font, size, box)]
51
- elsif comb
52
- size = auto_size(size, box.height).clamp(2.0, 144.0)
53
- [size, comb_body(font.encode(printable_text(value)), font, size, box, comb)]
55
+ pt = legal_size(size <= 0 ? DEFAULT_SIZE : size)
56
+ return [pt, multiline_body(value, font, pt, box)]
57
+ end
58
+
59
+ text = font.encode(printable_text(value))
60
+ if comb
61
+ pt = legal_size(auto_size(size, box.height))
62
+ [pt, comb_body(text, font, pt, box, comb)]
54
63
  else
55
- text = font.encode(printable_text(value))
56
- size = shrink_to_fit(text, font, auto_size(size, box.height), box.width)
57
- [size, "#{fmt(line_x(text, font, size, box))} #{fmt(baseline(box.height, font, size))} Td\n" \
58
- "(#{escape_literal(text)}) Tj\n"]
64
+ pt = legal_size(shrink_to_fit(text, font, auto_size(size, box.height), box.width))
65
+ [pt, single_line_body(text, font, pt, box)]
59
66
  end
60
67
  end
61
68
 
69
+ # The bounds every layout branch applies to the size it settled on.
70
+ def legal_size(size)
71
+ size.clamp(MIN_SIZE, MAX_SIZE)
72
+ end
73
+
74
+ def single_line_body(text, font, size, box)
75
+ "#{fmt(line_x(text, font, size, box))} #{fmt(baseline(box.height, font, size))} Td\n" \
76
+ "(#{escape_literal(text)}) Tj\n"
77
+ end
78
+
62
79
  # A /DA size of 0 means "fit the box"; acrofill caps that at 12pt.
80
+ # Multiline uses a flat 12pt instead, which is deliberate. A negative
81
+ # size is as meaningless as a zero one and takes the same path — the
82
+ # multiline branch already treats the two alike, and disagreeing here
83
+ # made one flag bit decide between a 12pt and a 2pt rendering.
63
84
  def auto_size(size, height)
64
- size.zero? ? [height * 0.66, 12.0].min : size
85
+ size.positive? ? size : [height * 0.66, DEFAULT_SIZE].min
65
86
  end
66
87
 
67
88
  def content(font_name, size, color_ops, body)
@@ -101,11 +122,12 @@ module Acrofill
101
122
  align.is_a?(Integer) ? align : 0
102
123
  end
103
124
 
125
+ # +text+ is already in the font's own codes. Left alignment needs no
126
+ # measurement, so it does not pay for one.
104
127
  def line_x(text, font, size, box)
105
- text_width = Metrics.string_width(text, font.widths, size)
106
128
  case box.align
107
- when 1 then [(box.width - text_width) / 2.0, PADDING].max
108
- when 2 then [box.width - PADDING - text_width, PADDING].max
129
+ when 1 then [(box.width - font.width_of(text, size)) / 2.0, PADDING].max
130
+ when 2 then [box.width - PADDING - font.width_of(text, size), PADDING].max
109
131
  else PADDING
110
132
  end
111
133
  end
@@ -123,7 +145,7 @@ module Acrofill
123
145
  end
124
146
  ty = fmt(baseline(box.height, font, size))
125
147
  chars.each_with_index.map do |char, index|
126
- x = ((first + index + 0.5) * cell) - (Metrics.string_width(char, font.widths, size) / 2.0)
148
+ x = ((first + index + 0.5) * cell) - (font.width_of(char, size) / 2.0)
127
149
  "1 0 0 1 #{fmt(x)} #{ty} Tm\n(#{escape_literal(char)}) Tj\n"
128
150
  end.join
129
151
  end
@@ -132,59 +154,58 @@ module Acrofill
132
154
  # that would fall below the box are clipped by the BBox.
133
155
  def multiline_body(value, font, size, box)
134
156
  max_width = box.width - (2 * PADDING)
157
+ # Wrapping runs on the Windows-1252 text, not on the font's codes: a
158
+ # /Encoding that moves the space glyph off code 32 would otherwise
159
+ # leave String#split no word boundary to find, and the whole value
160
+ # would be emitted as one unwrapped line running past the box.
135
161
  lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
136
- wrap_line(font.encode(printable_text(paragraph)), font, size, max_width)
162
+ wrap_line(printable_text(paragraph), font, size, max_width)
137
163
  end
138
164
 
139
165
  # pdftk spaces rows by the font's FontBBox extent and drops the first
140
- # baseline by that extent from the top of the box.
166
+ # baseline by that extent from the top of the box. A first row below
167
+ # the box floor is clipped away by the BBox, which would leave the
168
+ # widget blank while /V already holds the new value.
141
169
  leading = font.line_height(size)
142
- first_y = box.height - font.top(size) + TOP_OFFSET
170
+ first_y = [box.height - font.top(size) + TOP_OFFSET, 0.0].max
143
171
  body = "#{fmt(leading)} TL\n"
144
172
  previous_x = 0.0
145
173
  lines.each_with_index do |line, index|
146
- x = line_x(line, font, size, box)
174
+ encoded = font.encode(line)
175
+ x = line_x(encoded, font, size, box)
147
176
  body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
148
- body << "(#{escape_literal(line)}) Tj\nT*\n"
177
+ body << "(#{escape_literal(encoded)}) Tj\nT*\n"
149
178
  previous_x = x
150
179
  end
151
180
  body
152
181
  end
153
182
 
154
- # Greedy wrap. Line and space widths are accumulated incrementally so
155
- # the cost is O(total characters), not O(words * line-length).
183
+ # Greedy wrap over unencoded text. The running width is accumulated
184
+ # incrementally and each line is appended to in place, so the cost is
185
+ # O(total characters), not O(words * line-length).
156
186
  def wrap_line(text, font, size, max_width)
157
- space = Metrics.string_width(' ', font.widths, size)
158
- lines = ['']
159
- so_far = [0.0]
187
+ space = measure(' ', font, size)
188
+ lines = [+'']
189
+ width = 0.0
160
190
  text.split.each do |word|
161
- word_width = Metrics.string_width(word, font.widths, size)
191
+ word_width = measure(word, font, size)
162
192
  if lines.last.empty?
163
- lines[-1] = word
164
- so_far[-1] = word_width
165
- elsif so_far.last + space + word_width <= max_width
166
- lines[-1] = "#{lines.last} #{word}"
167
- so_far[-1] += space + word_width
193
+ lines[-1] = +word
194
+ width = word_width
195
+ elsif width + space + word_width <= max_width
196
+ lines.last << ' ' << word
197
+ width += space + word_width
168
198
  else
169
- lines << word
170
- so_far << word_width
199
+ lines << +word
200
+ width = word_width
171
201
  end
172
202
  end
173
203
  lines
174
204
  end
175
205
 
176
- # Array entries may legally be indirect objects, so each corner is
177
- # dereferenced; a rect that is not four numbers is unusable.
178
- def normalized_rect(rect)
179
- rect = @doc.deref(rect)
180
- return nil unless rect.is_a?(Array) && rect.size == 4
181
-
182
- nums = rect.map { |n| @doc.deref(n) }
183
- return nil unless nums.all?(Numeric)
184
-
185
- xs = [nums[0].to_f, nums[2].to_f].sort
186
- ys = [nums[1].to_f, nums[3].to_f].sort
187
- [xs[0], ys[0], xs[1], ys[1]]
206
+ # Width of unencoded text in the codes the font actually draws it with.
207
+ def measure(text, font, size)
208
+ font.width_of(font.encode(text), size)
188
209
  end
189
210
 
190
211
  # /DA is e.g. "/Helv 8 Tf 0 g": font + size around Tf, plus a colour.
@@ -201,9 +222,21 @@ module Acrofill
201
222
  return ['Helv', 0.0, '0 g'] unless tf && tf >= 2
202
223
 
203
224
  font = sanitize_font(tokens[tf - 2].to_s.delete_prefix('/'))
204
- size = tokens[tf - 1].to_f
205
- color = safe_color_ops(tokens[0...(tf - 2)] + tokens[(tf + 1)..])
206
- [font, size, color]
225
+ # The two sides of the removed "font size Tf" triple are scanned
226
+ # separately: concatenating them first makes tokens that were never
227
+ # adjacent in the /DA look like a well-formed operator, so
228
+ # "0.2 0.4 /Helv 12 Tf 0.6 rg" would emit "0.2 0.4 0.6 rg" and draw
229
+ # the value in a colour the template never asked for.
230
+ color = safe_color_ops(tokens[0...(tf - 2)], tokens[(tf + 1)..])
231
+ [font, size_of(tokens[tf - 1]), color]
232
+ end
233
+
234
+ # The /DA point size, which is template data: a non-finite one would
235
+ # poison every measurement it feeds, so it degrades to 0 — the same
236
+ # "fit the box" request a /DA that omits a size makes.
237
+ def size_of(token)
238
+ size = token.to_f
239
+ size.finite? ? size : 0.0
207
240
  end
208
241
 
209
242
  # Only a name made of PDF-regular characters may be written as /Name.
@@ -213,28 +246,38 @@ module Acrofill
213
246
 
214
247
  # Keep only well-formed colour-setting operators (g / rg / k) with the
215
248
  # right count of numeric operands immediately before them; drop anything
216
- # else the /DA might carry.
217
- def safe_color_ops(tokens)
218
- ops = []
219
- tokens.each_with_index do |tok, i|
249
+ # else the /DA might carry. Each segment is scanned on its own, so
250
+ # adjacency means adjacency in the original string.
251
+ def safe_color_ops(*segments)
252
+ ops = segments.flat_map { |tokens| color_ops_in(tokens || []) }
253
+ ops.empty? ? '0 g' : ops.join(' ')
254
+ end
255
+
256
+ def color_ops_in(tokens)
257
+ tokens.each_with_index.filter_map do |tok, i|
220
258
  arity = COLOR_OP_ARITY[tok]
221
- next unless arity
259
+ # `i >= arity` keeps the fixed-length slice below in bounds: a
260
+ # negative start would wrap around to the end of the array.
261
+ next unless arity && i >= arity
222
262
 
223
- operands = tokens[[i - arity, 0].max...i]
224
- next unless operands.size == arity && operands.all? { |o| o =~ /\A-?\d*\.?\d+\z/ }
263
+ operands = tokens[i - arity, arity]
264
+ next unless operands.all? { |o| NUMERIC_OPERAND.match?(o) }
225
265
 
226
- ops << "#{operands.join(' ')} #{tok}"
266
+ "#{operands.join(' ')} #{tok}"
227
267
  end
228
- ops.empty? ? '0 g' : ops.join(' ')
229
268
  end
230
269
 
231
270
  # Fixed sizes that overflow the box are scaled down so the whole value
232
271
  # stays visible (Acrobat-style best-fit; pdftk would clip instead).
233
272
  def shrink_to_fit(text, font, size, width)
234
273
  max_width = width - (2 * PADDING)
235
- text_width = Metrics.string_width(text, font.widths, size)
236
- size *= max_width / text_width if text_width > max_width && text_width.positive?
237
- size.clamp(2.0, 144.0)
274
+ text_width = font.width_of(text, size)
275
+ return size unless text_width > max_width && text_width.positive?
276
+
277
+ # The ratio is applied to +size+, never `size * max_width` first: a
278
+ # large /DA size makes that product overflow to Infinity, and
279
+ # Infinity / Infinity is a NaN that no later clamp can recover from.
280
+ size * (max_width / text_width)
238
281
  end
239
282
 
240
283
  def printable_text(value)
@@ -136,6 +136,27 @@ module Acrofill
136
136
  end
137
137
  end
138
138
 
139
+ # A /Rect or /BBox as [llx, lly, urx, ury]: the value and each corner may
140
+ # be indirect, and the corners may be given in either order. Returns nil
141
+ # when it is not four finite numbers — a non-finite corner would subtract
142
+ # into a NaN width or height, which every later comparison silently
143
+ # passes and every later clamp raises on.
144
+ def normalized_box(raw)
145
+ box = deref(raw)
146
+ return nil unless box.is_a?(Array) && box.size == 4
147
+
148
+ corners = box.map { |value| deref(value) }
149
+ return nil unless corners.all? { |corner| corner.is_a?(Numeric) && corner.to_f.finite? }
150
+
151
+ xs = [corners[0].to_f, corners[2].to_f].sort
152
+ ys = [corners[1].to_f, corners[3].to_f].sort
153
+ # Corners far enough apart still overflow when subtracted, and the
154
+ # width and height every caller derives have to be usable too.
155
+ return nil unless (xs[1] - xs[0]).finite? && (ys[1] - ys[0]).finite?
156
+
157
+ [xs[0], ys[0], xs[1], ys[1]]
158
+ end
159
+
139
160
  # Resolves an attribute inheritable through the /Parent chain
140
161
  # (field attributes like /FT, /DA, /Q or page attributes).
141
162
  def inherited_value(node, key)
@@ -9,7 +9,12 @@ module Acrofill
9
9
  # accepted and ignored — acrofill needs no FDF and is always UTF-aware.
10
10
  class Filler
11
11
  # Accepts and ignores a pdftk path argument for drop-in compatibility.
12
- def initialize(*); end
12
+ # Options given here are defaults for every #fill_form call, the way
13
+ # PdfForms.new(path, flatten: true) behaves — accepting them and then
14
+ # ignoring them would silently ship unflattened documents.
15
+ def initialize(_pdftk_path = nil, options = {}, **kwargs)
16
+ @options = (options.is_a?(Hash) ? options : {}).merge(kwargs)
17
+ end
13
18
 
14
19
  def fill_form(template, destination, data = {}, options = {})
15
20
  apply(Document.new(template), destination, data, options)
@@ -32,20 +37,37 @@ module Acrofill
32
37
  # leaking a raw Encoding error from deep inside the appearance code.
33
38
  def apply(doc, destination, data, options)
34
39
  form = Form.new(doc)
35
- data.each do |name, value|
40
+ (data || {}).each do |name, value|
36
41
  form.fill(normalize(name), normalize(value))
37
42
  end
38
- form.flatten! if options[:flatten] || options['flatten']
39
- File.binwrite(destination, Writer.new(doc).render)
43
+ form.flatten! if flatten?(options)
44
+ write(destination, Writer.new(doc).render)
40
45
  destination
41
46
  end
42
47
 
43
48
  private
44
49
 
50
+ def flatten?(options)
51
+ [options, @options].any? do |opts|
52
+ opts.is_a?(Hash) && (opts[:flatten] || opts['flatten'])
53
+ end
54
+ end
55
+
56
+ # The write is part of the public API's error contract, so a bad path
57
+ # surfaces as Acrofill::Error like every parse failure does, rather than
58
+ # as a bare Errno the caller's rescue does not name.
59
+ def write(destination, bytes)
60
+ File.binwrite(destination, bytes)
61
+ rescue SystemCallError, IOError, TypeError => e
62
+ raise Error, "could not write #{destination.inspect} (#{e.class}: #{e.message})"
63
+ end
64
+
45
65
  def normalize(value)
46
66
  return '' if value.nil?
47
67
 
48
68
  value.to_s.encode('UTF-8', invalid: :replace, undef: :replace).scrub
69
+ rescue EncodingError => e
70
+ raise Error, "could not normalize value (#{e.class}: #{e.message})"
49
71
  end
50
72
  end
51
73
  end
@@ -31,22 +31,27 @@ module Acrofill
31
31
  def flatten_page(page)
32
32
  annot_refs = @doc.deref(page[:Annots])
33
33
  annot_refs = [] unless annot_refs.is_a?(Array)
34
- annots = annot_refs.map { |a| [a, @doc.deref(a)] }
35
- widgets, others = annots.partition { |_ref, dict| dict.is_a?(Hash) && dict[:Subtype] == :Widget }
34
+ # A reference that does not resolve to a dictionary is dropped rather
35
+ # than carried over: it has no object to renumber, so the Writer would
36
+ # serialize it as a bare `null` inside /Annots, which is not a legal
37
+ # annotation array.
38
+ annots = annot_refs.filter_map do |ref|
39
+ dict = @doc.deref(ref)
40
+ [ref, dict] if dict.is_a?(Hash)
41
+ end
42
+ widgets, others = annots.partition { |_ref, dict| @doc.deref(dict[:Subtype]) == :Widget }
36
43
  return if widgets.empty?
37
44
 
38
- stamps = []
39
- widgets.each do |_ref, widget|
40
- stamp = stamp_operations(page, widget)
41
- stamps << stamp if stamp
42
- end
45
+ @pending = {}
46
+ stamps = widgets.filter_map { |_ref, widget| stamp_operations(widget) }
47
+ install_xobjects(page, @pending) unless @pending.empty?
43
48
 
44
49
  unless stamps.empty?
45
- wrap = ->(bytes) { @doc.add(StreamObject.new({}, bytes.b)) }
46
50
  derefed = @doc.deref(page[:Contents])
47
51
  contents = (derefed.is_a?(Array) ? derefed : [page[:Contents]]).compact
48
- contents = contents.map { |stream| @doc.ref_for(stream) }
49
- page[:Contents] = [wrap.call("q\n"), *contents, wrap.call("\nQ\n#{stamps.join("\n")}\n")]
52
+ .map { |stream| @doc.ref_for(stream) }
53
+ page[:Contents] = [content_stream("q\n"), *contents,
54
+ content_stream("\nQ\n#{stamps.join("\n")}\n")]
50
55
  end
51
56
 
52
57
  remaining = others.map(&:first)
@@ -57,12 +62,16 @@ module Acrofill
57
62
  end
58
63
  end
59
64
 
65
+ def content_stream(bytes)
66
+ @doc.add(StreamObject.new({}, bytes.b))
67
+ end
68
+
60
69
  # Returns content-stream operations placing the widget's normal
61
70
  # appearance onto the page, or nil when there is nothing to draw.
62
71
  # Implements the appearance-box algorithm of PDF 32000 §12.5.5: the
63
72
  # form's /Matrix is applied to its BBox, and the resulting extent is
64
73
  # mapped onto the annotation rectangle.
65
- def stamp_operations(page, widget)
74
+ def stamp_operations(widget)
66
75
  # Flattening produces the document as it is displayed, so a widget the
67
76
  # viewer would not show is dropped rather than burned in: /F Hidden,
68
77
  # and NoView, which means "not on screen" (PDF 32000 §12.5.3). pdftk
@@ -72,11 +81,15 @@ module Acrofill
72
81
 
73
82
  ap_ref = normal_appearance(widget)
74
83
  xobject = @doc.deref(ap_ref)
75
- dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
76
- return nil unless dict.is_a?(Hash)
77
-
78
- bbox = normalize_box(@doc.deref(dict[:BBox]))
79
- rect = normalize_box(@doc.deref(widget[:Rect]))
84
+ dict = appearance_dict(xobject)
85
+ return nil unless dict
86
+
87
+ bbox = @doc.normalized_box(dict[:BBox])
88
+ # /Rect is inheritable through the /Parent chain, and Appearance#build
89
+ # honours that when it draws the value. Reading only widget[:Rect] here
90
+ # would stamp nothing for a kid widget whose rectangle lives on the
91
+ # parent field — filling the value and then silently dropping it.
92
+ rect = @doc.normalized_box(@doc.inherited_value(widget, :Rect))
80
93
  return nil unless bbox && rect
81
94
 
82
95
  # Appearance streams are form XObjects, but /Type and /Subtype are
@@ -88,14 +101,31 @@ module Acrofill
88
101
  bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
89
102
  bw = bx1 - bx0
90
103
  bh = by1 - by0
91
- return nil if bw <= 0 || bh <= 0
104
+ # A degenerate annotation rectangle scales to a singular matrix, which
105
+ # PDF 32000 §8.3.3 forbids; Appearance#build rejects the same geometry.
106
+ return nil unless positive_extent?(bw, bh) && positive_extent?(urx - llx, ury - lly)
92
107
 
93
108
  sx = (urx - llx) / bw
94
109
  sy = (ury - lly) / bh
95
- name = register_xobject(page, ap_ref)
96
110
  matrix = [sx, 0, 0, sy, llx - (bx0 * sx), lly - (by0 * sy)]
97
- ops = matrix.map { |n| Serializer.format_number(n.to_f) }
98
- "q #{ops.join(' ')} cm /#{name} Do Q"
111
+ return nil unless matrix.all? { |n| n.to_f.finite? }
112
+
113
+ name = register_xobject(ap_ref)
114
+ "q #{matrix.map { |n| Serializer.format_number(n.to_f) }.join(' ')} cm /#{name} Do Q"
115
+ end
116
+
117
+ # The dictionary of an appearance XObject, or nil. A /AP /N that derefs
118
+ # to something other than a stream has no dictionary — asking a plain
119
+ # Hash or Array for #hash would return Object#hash, an Integer.
120
+ def appearance_dict(xobject)
121
+ case xobject
122
+ when StreamObject then xobject.dict
123
+ when PDF::Reader::Stream then xobject.hash
124
+ end
125
+ end
126
+
127
+ def positive_extent?(width, height)
128
+ width.finite? && height.finite? && width.positive? && height.positive?
99
129
  end
100
130
 
101
131
  # Bounding box of the (already normalized) BBox corners after the
@@ -103,8 +133,10 @@ module Acrofill
103
133
  def transformed_bbox(bbox, matrix)
104
134
  x0, y0, x1, y1 = bbox
105
135
  matrix = matrix.map { |m| @doc.deref(m) } if matrix.is_a?(Array)
136
+ # A non-finite entry is as malformed as a missing one: it multiplies
137
+ # into the NaN that Array#min then raises on.
106
138
  return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
107
- matrix.all?(Numeric)
139
+ matrix.all? { |m| m.is_a?(Numeric) && m.to_f.finite? }
108
140
 
109
141
  a, b, c, d, e, f = matrix.map(&:to_f)
110
142
  xs = []
@@ -113,6 +145,11 @@ module Acrofill
113
145
  xs << ((a * x) + (c * y) + e)
114
146
  ys << ((b * x) + (d * y) + f)
115
147
  end
148
+ # Finite entries still multiply and add into a NaN (Infinity plus its
149
+ # negation), which Array#min raises on rather than returning; a
150
+ # transform that overflows is as unusable as a malformed one.
151
+ return [x0, y0, x1, y1] unless xs.all?(&:finite?) && ys.all?(&:finite?)
152
+
116
153
  [xs.min, ys.min, xs.max, ys.max]
117
154
  end
118
155
 
@@ -132,35 +169,27 @@ module Acrofill
132
169
  normal
133
170
  end
134
171
 
135
- # Derefs each element (array entries may legally be indirect objects)
136
- # and returns [llx, lly, urx, ury], or nil when the box is not four
137
- # numbers.
138
- def normalize_box(box)
139
- return nil unless box.is_a?(Array) && box.size == 4
140
-
141
- nums = box.map { |n| @doc.deref(n) }
142
- return nil unless nums.all?(Numeric)
143
-
144
- xs = [nums[0].to_f, nums[2].to_f].sort
145
- ys = [nums[1].to_f, nums[3].to_f].sort
146
- [xs[0], ys[0], xs[1], ys[1]]
172
+ # Names the stamp and records it; the page's /Resources are rewritten
173
+ # once per page by #install_xobjects rather than once per widget.
174
+ def register_xobject(ap_ref)
175
+ @stamp_counter += 1
176
+ name = :"AcrofillAP#{@stamp_counter}"
177
+ @pending[name] = @doc.ref_for(ap_ref)
178
+ name
147
179
  end
148
180
 
149
181
  # A page's /Resources (and its /XObject subdictionary) are template
150
182
  # data: anything that is not a dictionary is replaced rather than
151
- # indexed, which would raise TypeError on an Array or a stream.
152
- def register_xobject(page, ap_ref)
183
+ # indexed, which would raise TypeError on an Array or a stream. Both are
184
+ # copied before mutation, since a /Resources reached through /Parent is
185
+ # shared with every other page under that node.
186
+ def install_xobjects(page, pending)
153
187
  resources = @doc.deref(page[:Resources]) || @doc.deref(@doc.inherited_value(page, :Resources))
154
188
  resources = resources.is_a?(Hash) ? resources.dup : {}
155
189
  xobjects = @doc.deref(resources[:XObject])
156
190
  xobjects = xobjects.is_a?(Hash) ? xobjects.dup : {}
157
-
158
- @stamp_counter += 1
159
- name = :"AcrofillAP#{@stamp_counter}"
160
- xobjects[name] = @doc.ref_for(ap_ref)
161
- resources[:XObject] = xobjects
191
+ resources[:XObject] = xobjects.merge(pending)
162
192
  page[:Resources] = resources
163
- name
164
193
  end
165
194
  end
166
195
  end
@@ -30,7 +30,6 @@ module Acrofill
30
30
  # not consult /MissingWidth (verified — setting it changes nothing).
31
31
  OUT_OF_RANGE_WIDTH = 0
32
32
  SUBSET_PREFIX = /\A[A-Z]{6}\+/
33
- IDENTITY = Array.new(256) { |code| code }.freeze
34
33
 
35
34
  def initialize(doc, acroform)
36
35
  @doc = doc
@@ -42,7 +41,7 @@ module Acrofill
42
41
  # Metrics::Font for the font a /DA string names.
43
42
  def metrics(resource_name)
44
43
  key = resource_name.to_sym
45
- @metrics[key] ||= build(@doc.deref(entry(key)))
44
+ @metrics[key] ||= build(@doc.deref(dr_fonts[key]))
46
45
  end
47
46
 
48
47
  # Indirect reference to that font, for the appearance /Resources. Fonts
@@ -53,8 +52,13 @@ module Acrofill
53
52
  key = resource_name.to_sym
54
53
  @references[key] ||=
55
54
  begin
56
- found = entry(key)
57
- found ? @doc.ref_for(found) : fallback
55
+ found = dr_fonts[key]
56
+ # #metrics already degrades to standard Helvetica for a /DR entry
57
+ # that is not a font dictionary; writing that same entry into the
58
+ # appearance's /Resources would point /Tf at a non-font object (or
59
+ # at a dangling reference the Writer serializes as `null`), so the
60
+ # two have to agree on what counts as usable.
61
+ @doc.deref(found).is_a?(Hash) ? @doc.ref_for(found) : fallback
58
62
  end
59
63
  end
60
64
 
@@ -69,10 +73,6 @@ module Acrofill
69
73
  end
70
74
  end
71
75
 
72
- def entry(key)
73
- dr_fonts[key]
74
- end
75
-
76
76
  def fallback
77
77
  @fallback ||=
78
78
  @doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
@@ -84,32 +84,9 @@ module Acrofill
84
84
  name = base_font_name(dict)
85
85
  standard = Metrics.standard_font(name)
86
86
  ascender, descender, top, bottom = vertical(dict, standard)
87
- Metrics::Font.new(widths(dict) || Metrics.widths_for(name),
88
- ascender, descender, top, bottom, remap(dict)).freeze
89
- end
90
-
91
- # A 256-entry code translation table when /Encoding /Differences moves
92
- # glyphs to codes other than their WinAnsi ones, else nil. Without it a
93
- # value is drawn with whatever glyphs happen to sit at its bytes: a font
94
- # that puts "A" at code 90 would render "AZ" as "ZA".
95
- def remap(dict)
96
- differences = differences(dict)
97
- return nil if differences.empty?
98
-
99
- by_code = {}
100
- Metrics::WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
101
- by_code[index + Metrics::FIRST_CODE] = glyph if glyph
102
- end
103
- differences.each { |code, glyph| by_code[code] = glyph }
104
-
105
- at = {}
106
- by_code.each { |code, glyph| at[glyph] ||= code }
107
- table = Array.new(256) { |code| code }
108
- Metrics::WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
109
- code = glyph && at[glyph]
110
- table[index + Metrics::FIRST_CODE] = code if code
111
- end
112
- table == IDENTITY ? nil : table.freeze
87
+ Metrics.build_font(widths(dict) || Metrics.widths_for(name),
88
+ ascender, descender, top, bottom,
89
+ Metrics.remap_for(differences(dict)))
113
90
  end
114
91
 
115
92
  # /Differences is a flat array where an integer restarts the code
@@ -163,11 +140,11 @@ module Acrofill
163
140
 
164
141
  def vertical(dict, standard)
165
142
  descriptor = @doc.deref(dict[:FontDescriptor])
166
- descriptor = nil unless descriptor.is_a?(Hash)
143
+ descriptor = {} unless descriptor.is_a?(Hash)
167
144
  bottom, top = font_bbox(descriptor)
168
145
  [
169
- (descriptor && number(descriptor[:Ascent])) || standard&.ascender || DEFAULT_ASCENDER,
170
- (descriptor && number(descriptor[:Descent])) || standard&.descender || DEFAULT_DESCENDER,
146
+ number(descriptor[:Ascent]) || standard&.ascender || DEFAULT_ASCENDER,
147
+ number(descriptor[:Descent]) || standard&.descender || DEFAULT_DESCENDER,
171
148
  top || standard&.bbox_top || DEFAULT_BBOX_TOP,
172
149
  bottom || standard&.bbox_bottom || DEFAULT_BBOX_BOTTOM
173
150
  ]
@@ -175,7 +152,7 @@ module Acrofill
175
152
 
176
153
  # [lower y, upper y] of /FontBBox, or [nil, nil] when it is unusable.
177
154
  def font_bbox(descriptor)
178
- box = descriptor && @doc.deref(descriptor[:FontBBox])
155
+ box = @doc.deref(descriptor[:FontBBox])
179
156
  return [nil, nil] unless box.is_a?(Array) && box.size == 4
180
157
 
181
158
  [number(box[1]), number(box[3])]
data/lib/acrofill/form.rb CHANGED
@@ -7,6 +7,11 @@ module Acrofill
7
7
  MULTILINE_FLAG = 1 << 12
8
8
  PUSHBUTTON_FLAG = 1 << 16
9
9
  COMB_FLAG = 1 << 24
10
+ # Values that uncheck a button, compared case-insensitively: "Off"
11
+ # unchecking while "off" ticked the box was a silent data error. A state
12
+ # the template actually names is matched first, so a checkbox whose on
13
+ # state is literally called "no" stays checkable.
14
+ OFF_VALUES = ['', 'off', 'false', 'no', '0'].freeze
10
15
 
11
16
  Field = Struct.new(:name, :type, :value, :states, keyword_init: true)
12
17
 
@@ -44,11 +49,17 @@ module Acrofill
44
49
  groups = @fields.fetch(name, [])
45
50
  return false if groups.empty?
46
51
 
47
- case field_type(groups.first[:node])
48
- when :Btn then fill_button(groups, value)
49
- when :Tx, :Ch, nil then fill_text_groups(groups, value)
50
- else false # signatures and unknown types are left untouched
51
- end
52
+ # Several field dicts may share one name without sharing one /FT.
53
+ # Dispatching on the first one's type would push a checkbox through
54
+ # the text path, overwriting its /AP state dictionary with a text
55
+ # appearance, so each type is handled with its own groups.
56
+ groups.group_by { |group| field_type(group[:node]) }.map do |type, typed|
57
+ case type
58
+ when :Btn then fill_button(typed, value)
59
+ when :Tx, :Ch, nil then fill_text_groups(typed, value)
60
+ else false # signatures and unknown types are left untouched
61
+ end
62
+ end.any?
52
63
  end
53
64
 
54
65
  # Stamps every visible widget appearance into its page's content and
@@ -82,12 +93,6 @@ module Acrofill
82
93
  true
83
94
  end
84
95
 
85
- def build_appearance(node, widget, value, multiline, comb)
86
- return nil if value.empty?
87
-
88
- @appearance.build(node, widget, value, multiline: multiline, comb: comb)
89
- end
90
-
91
96
  def fill_text(group, value)
92
97
  node = group[:node]
93
98
  node[:V] = pdf_text_string(value)
@@ -98,7 +103,7 @@ module Acrofill
98
103
  comb = comb_cells(node, flags, multiline)
99
104
  group[:widgets].each do |widget|
100
105
  widget.delete(:AS)
101
- ap_ref = build_appearance(node, widget, value, multiline, comb)
106
+ ap_ref = @appearance.build(node, widget, value, multiline:, comb:) unless value.empty?
102
107
  # An unusable geometry yields no appearance; dropping /AP is still
103
108
  # required, or the widget would keep rendering the *previous* value
104
109
  # while /V already holds the new one.
@@ -137,7 +142,7 @@ module Acrofill
137
142
  state =
138
143
  if named
139
144
  named
140
- elsif ['', 'Off', 'false', 'no'].include?(value)
145
+ elsif OFF_VALUES.include?(value.downcase)
141
146
  :Off
142
147
  elsif states.size == 1
143
148
  states.first
@@ -233,7 +238,15 @@ module Acrofill
233
238
  raw.byteslice(2..).force_encoding('UTF-16BE')
234
239
  .encode('UTF-8', invalid: :replace, undef: :replace)
235
240
  else
236
- raw.dup.force_encoding('UTF-8').scrub
241
+ as_utf8 = raw.dup.force_encoding('UTF-8')
242
+ return as_utf8 if as_utf8.valid_encoding?
243
+
244
+ # Not UTF-8, so it is PDFDocEncoded (or Latin-1) — decode it rather
245
+ # than scrub it. Scrubbing turns every accented byte into U+FFFD,
246
+ # which renames the field to something no caller can pass back in,
247
+ # leaving it permanently unfillable.
248
+ raw.dup.force_encoding('Windows-1252')
249
+ .encode('UTF-8', invalid: :replace, undef: :replace)
237
250
  end
238
251
  end
239
252
 
@@ -18,6 +18,7 @@ module Acrofill
18
18
  WINDOWS_1252 = Encoding::Windows_1252
19
19
  # Encodings whose bytes are already the codes the font is drawn with.
20
20
  BYTE_ENCODINGS = [Encoding::Windows_1252, Encoding::BINARY].freeze
21
+ IDENTITY = Array.new(256) { |code| code }.freeze
21
22
 
22
23
  # The standard-14 text cuts, indexed by bold + 2 * italic per family.
23
24
  CUTS = {
@@ -28,8 +29,12 @@ module Acrofill
28
29
  # Family and weight hints for BaseFont names outside the standard 14.
29
30
  SERIF = /times|roman|serif|georgia|garamond/
30
31
  SANS = /sans/
31
- FIXED = /courier|mono/
32
- BOLD = /bold|black|heavy/
32
+ # "mono" must not swallow the Monotype foundry prefix: MonotypeCorsiva is
33
+ # a proportional script face, not a typewriter one.
34
+ FIXED = /courier|mono(?!type)/
35
+ # Likewise "black" is a weight only at the end of a word — Blackadder and
36
+ # Blackoak are display faces, Arial-Black and Roboto-Black are weights.
37
+ BOLD = /bold|black(?![a-z])|heavy/
33
38
  ITALIC = /italic|oblique/
34
39
 
35
40
  TABLES = {
@@ -194,7 +199,8 @@ module Acrofill
194
199
  # Everything the appearance code needs about one resolved face. +remap+
195
200
  # is a 256-entry code translation table, or nil when the font draws
196
201
  # WinAnsi codes as they are.
197
- Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom, :remap) do
202
+ Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom, :remap,
203
+ :code_widths) do
198
204
  # Windows-1252 text as the byte codes this font draws it with.
199
205
  def encode(text)
200
206
  return text unless remap
@@ -202,6 +208,12 @@ module Acrofill
202
208
  text.b.each_byte.map { |code| remap[code] }.pack('C*')
203
209
  end
204
210
 
211
+ # Width of +text+ at +size+ points, in points. +text+ is in this font's
212
+ # own codes (the output of #encode), so it is measured in that space.
213
+ def width_of(text, size)
214
+ Metrics.string_width(text, code_widths, size)
215
+ end
216
+
205
217
  def ascent(size) = ascender * size / 1000.0
206
218
 
207
219
  def descent(size) = -descender * size / 1000.0
@@ -212,21 +224,79 @@ module Acrofill
212
224
  def line_height(size) = (bbox_top - bbox_bottom) * size / 1000.0
213
225
  end
214
226
 
227
+ # A frozen Font, with its code-space width table derived once.
228
+ def self.build_font(widths, ascender, descender, top, bottom, remap = nil)
229
+ Font.new(widths, ascender, descender, top, bottom, remap,
230
+ code_width_table(widths, remap)).freeze
231
+ end
232
+
233
+ # Width of the glyph this font draws at each of the 256 byte codes.
234
+ # +widths+ is indexed by WinAnsi code, but /Differences can move a glyph
235
+ # onto any code — including one below 32, which no WinAnsi-indexed table
236
+ # can express — and the appearance stream emits the moved code. Measuring
237
+ # in the font's own code space is what keeps rendering and metrics in
238
+ # step; a code the font draws nothing at is worth nothing, not the
239
+ # average glyph width the old WinAnsi lookup fell back to.
240
+ def self.code_width_table(widths, remap)
241
+ table = Array.new(256, 0)
242
+ (FIRST_CODE..LAST_CODE).each do |code|
243
+ width = widths[code - FIRST_CODE] || DEFAULT_WIDTH
244
+ table[remap ? remap[code] : code] = width
245
+ end
246
+ table.freeze
247
+ end
248
+
215
249
  FONTS = VERTICAL.to_h do |name, (asc, desc, top, bottom)|
216
- [name, Font.new(WIDTHS[name], asc, desc, top, bottom).freeze]
250
+ [name, build_font(WIDTHS[name], asc, desc, top, bottom)]
217
251
  end.freeze
218
252
 
219
- # Width of +str+ at +size+ points. +widths+ is a table from #widths_for,
220
- # or a BaseFont name (resolved here, at the cost of the lookup).
253
+ # Width of +str+ at +size+ points. +widths+ is a 256-entry code-space
254
+ # table from #code_width_table, or a BaseFont name (resolved here, at
255
+ # the cost of the lookup).
221
256
  def self.string_width(str, widths, size)
222
- widths = widths_for(widths) unless widths.is_a?(Array)
223
- str = to_win_ansi(str)
224
- units = str.each_byte.sum do |code|
225
- (code >= FIRST_CODE && widths[code - FIRST_CODE]) || DEFAULT_WIDTH
226
- end
257
+ widths = font_for(widths).code_widths unless widths.is_a?(Array)
258
+ units = to_win_ansi(str).each_byte.sum { |code| widths[code] }
227
259
  units * size / 1000.0
228
260
  end
229
261
 
262
+ # A 256-entry code translation table for a font whose /Encoding moves
263
+ # glyphs off their WinAnsi codes, or nil when nothing moves. +differences+
264
+ # is {code => glyph name}, as /Differences declares it.
265
+ def self.remap_for(differences)
266
+ return nil if differences.empty?
267
+
268
+ at = drawn_at(differences)
269
+ table = IDENTITY.dup
270
+ WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
271
+ table[index + FIRST_CODE] = at[glyph] if at[glyph]
272
+ end
273
+ table == IDENTITY ? nil : table.freeze
274
+ end
275
+
276
+ # glyph => the code this font draws it at. The WinAnsi codes are claimed
277
+ # first, in ascending order, so a glyph keeps the code it normally has; a
278
+ # /Differences entry parked on a code WinAnsi leaves undefined (129, 141,
279
+ # 143, 144, 157, or anything below 32) only wins a glyph that no WinAnsi
280
+ # code draws. That preference matters because widths are looked up by
281
+ # code: an embedded /Widths array covers the codes the base encoding uses,
282
+ # not the holes a subset font parks its spare glyphs in.
283
+ #
284
+ # Codes outside a byte are ignored rather than trusted: the table they
285
+ # would land in is packed with 'C*', which truncates instead of failing.
286
+ def self.drawn_at(differences)
287
+ at = {}
288
+ WIN_ANSI_GLYPHS.each_with_index do |win_ansi, index|
289
+ next unless win_ansi
290
+
291
+ code = index + FIRST_CODE
292
+ at[differences[code] || win_ansi] ||= code
293
+ end
294
+ differences.each do |code, glyph|
295
+ at[glyph] ||= code if code.is_a?(Integer) && code.between?(0, 255)
296
+ end
297
+ at
298
+ end
299
+
230
300
  # Metrics for a BaseFont name. Standard-14 names resolve directly;
231
301
  # anything else (ArialMT, TimesNewRomanPS-BoldMT, a template's embedded
232
302
  # face) is classified by family and weight, which is far closer than
@@ -276,6 +346,6 @@ module Acrofill
276
346
  str.encode(WINDOWS_1252, invalid: :replace, undef: :replace, replace: '?')
277
347
  end
278
348
 
279
- private_class_method :serif?, :weight_index, :to_win_ansi
349
+ private_class_method :serif?, :weight_index, :to_win_ansi, :drawn_at
280
350
  end
281
351
  end
@@ -4,7 +4,7 @@ module Acrofill
4
4
  # A pre-parsed, reusable template. Parsing is the dominant cost of a
5
5
  # fill (tokenizing and inflating the whole file); Template pays it once
6
6
  # and restores a pristine object graph from a Marshal snapshot for each
7
- # subsequent fill — roughly 50x faster than re-parsing per fill.
7
+ # subsequent fill — measured at 7-8x faster per fill on real forms.
8
8
  #
9
9
  # template = Acrofill::Template.new('claim_form.pdf')
10
10
  # template.fill_form('a.pdf', { 'Name' => 'Jane' }, flatten: true)
@@ -15,11 +15,20 @@ module Acrofill
15
15
  # fill works on its own restored copy.
16
16
  class Template
17
17
  def initialize(path)
18
- doc = Document.new(path)
19
- @snapshot = doc.snapshot
18
+ @source = absolute(path)
19
+ @snapshot = Document.new(path).snapshot
20
20
  end
21
21
 
22
22
  def fill_form(destination, data = {}, options = {})
23
+ # Template#fill_form takes the destination first; Filler#fill_form
24
+ # takes the template first. Passing the template here is the natural
25
+ # slip, and since Template never re-reads the file the overwrite would
26
+ # go unnoticed until another consumer opened the corrupted template.
27
+ if @source && absolute(destination) == @source
28
+ raise Error, 'destination is the template itself; ' \
29
+ 'Template#fill_form takes (destination, data, options)'
30
+ end
31
+
23
32
  Filler.new.apply(Document.restore(@snapshot), destination, data, options)
24
33
  end
25
34
 
@@ -31,5 +40,15 @@ module Acrofill
31
40
  def field_names
32
41
  fields.map(&:name)
33
42
  end
43
+
44
+ private
45
+
46
+ # The path +input+ names, resolved so that "form.pdf" and "./form.pdf"
47
+ # compare equal, or nil when it is not a path at all (an IO, say).
48
+ # Pure string work: nothing here touches the filesystem.
49
+ def absolute(input)
50
+ path = input.respond_to?(:to_path) ? input.to_path : input
51
+ File.expand_path(path) if path.is_a?(String)
52
+ end
34
53
  end
35
54
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acrofill
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.1'
5
5
  end
data/lib/acrofill.rb CHANGED
@@ -15,9 +15,9 @@ require_relative 'acrofill/template'
15
15
  module Acrofill
16
16
  class Error < StandardError; end
17
17
 
18
- # Mirrors the PdfForms.new(pdftk_path) constructor shape.
19
- def self.new(*)
20
- Filler.new(*)
18
+ # Mirrors the PdfForms.new(pdftk_path, options) constructor shape.
19
+ def self.new(*, **)
20
+ Filler.new(*, **)
21
21
  end
22
22
 
23
23
  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.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - stiig
@@ -50,7 +50,7 @@ licenses:
50
50
  - MIT
51
51
  metadata:
52
52
  homepage_uri: https://github.com/stiig/acrofill
53
- source_code_uri: https://github.com/stiig/acrofill
53
+ source_code_uri: https://github.com/stiig/acrofill/tree/main
54
54
  changelog_uri: https://github.com/stiig/acrofill/blob/main/CHANGELOG.md
55
55
  bug_tracker_uri: https://github.com/stiig/acrofill/issues
56
56
  rubygems_mfa_required: 'true'