acrofill 0.3.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: d7b9246e478c6ab2f8206ba44e766a2b4e9e9a946a48245d171d7e212bac54d2
4
- data.tar.gz: e6f70298a5008e1419089328788cb1d66908cd3e2edfa2e3bd724e0a220ff176
3
+ metadata.gz: 2004f19b67792da81c96fb674f6659f9a8da74929334c73a7a46705337cf3545
4
+ data.tar.gz: 427e766a9775dfdbc5d6651c993418b0bf6d29694bc9462cae69aa719e242532
5
5
  SHA512:
6
- metadata.gz: 4f858c6d8e34cf948d8ef554d31d9e0a7187408f256fadff63c15bd4af3ad6ad6e61329770e9db506482f179635b9305393d432a9176742893aa45643810f22c
7
- data.tar.gz: 75b8174ebb610ec9be61a7b214a69a47071d5aa797bb13378917eb3d41b4279c9ee81e1348641703dd29e5436ca1dcda30148b3e50c70759b2d47fad8c137cb1
6
+ metadata.gz: c8b11295461d44e3ce2fe61ecd3b066f70fe60f69fd84ad9d38b7606eae4cc08d059ffd921e65cc75cc768b454af37e822d9f7b823d03ba6e768200aa0e3457e
7
+ data.tar.gz: fc4eae29263ce69cd1ac90c015b02f2b321fe6e8e3df8c5091b6888024fcc084a54919b158edfd5a7209ab426b697e37da908409a48ca81dfa9b5b00b477b9c1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,104 @@ 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
+
83
+ ## [0.4.0] - 2026-07-27
84
+
85
+ ### Added
86
+
87
+ - Comb fields (`/Ff` bit 25 with `/MaxLen`) lay each character out centered
88
+ in its own cell, `/Q` choosing which run of cells the value occupies,
89
+ matching pdftk cell for cell. They previously rendered as plain text.
90
+
91
+ ### Fixed
92
+
93
+ - A value is now written in the codes its font actually draws it with. A
94
+ `/DR` font whose `/Encoding` carries a `/Differences` array moves glyphs
95
+ to other codes; writing the raw bytes drew whatever glyph happened to sit
96
+ there — a font mapping "A" to code 90 rendered "AZ" as "ZA". Widths
97
+ follow the emitted code, so measurement and rendering stay in step.
98
+
99
+ ### Changed
100
+
101
+ - Flattening no longer stamps widgets a viewer would not display: `/F`
102
+ Hidden (bit 2) was already dropped, and NoView (bit 6, "not on screen",
103
+ PDF 32000 §12.5.3) now is too. pdftk stamps both, but burning in a
104
+ widget the template author concealed makes hidden values permanently
105
+ visible, so parity loses here. Widgets that are merely non-printing are
106
+ still stamped: they are what the viewer shows.
107
+
10
108
  ## [0.3.0] - 2026-07-27
11
109
 
12
110
  ### Fixed
@@ -142,7 +240,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
142
240
  - `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
143
241
  `field_names`).
144
242
 
145
- [Unreleased]: https://github.com/stiig/acrofill/compare/v0.3.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
245
+ [0.4.0]: https://github.com/stiig/acrofill/compare/v0.3.0...v0.4.0
146
246
  [0.3.0]: https://github.com/stiig/acrofill/compare/v0.2.0...v0.3.0
147
247
  [0.2.0]: https://github.com/stiig/acrofill/compare/v0.1.2...v0.2.0
148
248
  [0.1.2]: https://github.com/stiig/acrofill/compare/v0.1.1...v0.1.2
data/README.md CHANGED
@@ -91,11 +91,16 @@ your own templates with:
91
91
  ruby benchmark/geometry_diff.rb path/to/form.pdf
92
92
  ```
93
93
 
94
- Three differences are deliberate: acrofill shrinks an overlong value to fit
95
- where pdftk clips it, it writes Windows-1252 for non-ASCII values where
96
- pdftk emits UTF-8 bytes into a WinAnsi font (which renders as mojibake), and
97
- it caps auto-sized (`0 Tf`) text at 12pt where pdftk scales it to fill the
98
- box. See the changelog for the measured numbers.
94
+ Flattened output was checked the same way: on those four forms every
95
+ stamped fragment lands where pdftk stamps it, bar the fields covered by the
96
+ deliberate differences below.
97
+
98
+ Four differences are deliberate: acrofill shrinks an overlong value to fit
99
+ where pdftk clips it; it writes Windows-1252 for non-ASCII values where
100
+ pdftk emits UTF-8 bytes into a WinAnsi font (which renders as mojibake); it
101
+ caps auto-sized (`0 Tf`) text at 12pt where pdftk scales it to fill the box;
102
+ and it drops widgets flagged Hidden or NoView when flattening instead of
103
+ burning them into the page. See the changelog for the measured numbers.
99
104
 
100
105
  ## Performance
101
106
 
@@ -194,6 +199,7 @@ Supported:
194
199
  rather than all measured as Helvetica.
195
200
  - Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
196
201
  using the template's own appearance states.
202
+ - Comb fields (`/Ff` bit 25) — one character centered per `/MaxLen` cell.
197
203
  - Choice fields (`/Ch`) — value set and rendered like text.
198
204
  - Flattening — every visible widget appearance is stamped into the page
199
205
  content; widget annotations and the AcroForm dictionary are removed.
@@ -204,8 +210,7 @@ Not supported (rejected or ignored, never a hard crash):
204
210
 
205
211
  - Encrypted documents (raise `Acrofill::Error`), XFA forms, digital
206
212
  signatures, JavaScript actions.
207
- - Push buttons; comb fields render as plain text; rich text (`/RV`) is
208
- dropped on fill.
213
+ - Push buttons; rich text (`/RV`) is dropped on fill.
209
214
  - Glyphs outside Windows-1252 in generated appearances (stored values
210
215
  keep full Unicode; unrenderable glyphs appear as `?`).
211
216
 
@@ -7,8 +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
13
+
14
+ # The drawable area of one widget: its size and the field's /Q.
15
+ Box = Struct.new(:width, :height, :align)
10
16
  # Colour-setting operators allowed in a /DA string, and their operand counts.
11
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/
12
20
 
13
21
  def initialize(doc, acroform)
14
22
  @doc = doc
@@ -18,51 +26,81 @@ module Acrofill
18
26
 
19
27
  # Returns a Reference to the new appearance XObject, or nil when the
20
28
  # widget geometry is unusable.
21
- def build(field_node, widget, value, multiline: false)
22
- rect = normalized_rect(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
29
+ def build(field_node, widget, value, multiline: false, comb: nil)
30
+ rect = @doc.normalized_box(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
23
31
  return nil unless rect
24
32
 
25
- width = rect[2] - rect[0]
26
- height = rect[3] - rect[1]
27
- return nil if width <= 0 || height <= 0
33
+ box = Box.new(rect[2] - rect[0], rect[3] - rect[1], alignment(field_node))
34
+ return nil if box.width <= 0 || box.height <= 0
28
35
 
29
36
  font_name, size, color_ops = parse_da(field_node)
30
37
  font = @fonts.metrics(font_name)
31
- align = alignment(field_node)
38
+ size, body = draw(value, font, size, box, multiline: multiline, comb: comb)
32
39
 
33
- body =
34
- if multiline
35
- size = 12.0 if size <= 0
36
- size = size.clamp(2.0, 144.0)
37
- multiline_body(value, font, size, width, height, align)
38
- else
39
- text = printable_text(value)
40
- size = [height * 0.66, 12.0].min if size.zero?
41
- size = shrink_to_fit(text, font, size, width)
42
- "#{fmt(line_x(text, font, size, width, align))} #{fmt(baseline(height, font, size))} Td\n" \
43
- "(#{escape_literal(text)}) Tj\n"
44
- end
40
+ @doc.add(StreamObject.new(appearance_dict(font_name, box),
41
+ content(font_name, size, color_ops, body).b))
42
+ end
43
+
44
+ private
45
+
46
+ def fmt(num)
47
+ Serializer.format_number(num.to_f)
48
+ end
49
+
50
+ # The operators drawing +value+, and the point size they were laid out
51
+ # at — auto-sizing and shrink-to-fit both adjust the /DA size.
52
+ def draw(value, font, size, box, multiline:, comb:)
53
+ # Multiline wraps the raw value, so it encodes paragraph by paragraph.
54
+ if multiline
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)]
63
+ else
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)]
66
+ end
67
+ end
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
+
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.
84
+ def auto_size(size, height)
85
+ size.positive? ? size : [height * 0.66, DEFAULT_SIZE].min
86
+ end
45
87
 
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"
88
+ def content(font_name, size, color_ops, body)
89
+ stream = +"/Tx BMC\nq\nBT\n"
90
+ stream << "#{color_ops}\n" unless color_ops.empty?
91
+ stream << "/#{font_name} #{fmt(size)} Tf\n"
92
+ stream << body
93
+ stream << "ET\nQ\nEMC\n"
94
+ end
51
95
 
52
- dict = {
96
+ def appearance_dict(font_name, box)
97
+ {
53
98
  Type: :XObject,
54
99
  Subtype: :Form,
55
100
  FormType: 1,
56
- BBox: [0, 0, width, height],
101
+ BBox: [0, 0, box.width, box.height],
57
102
  Resources: { Font: { font_name.to_sym => @fonts.reference(font_name) } }
58
103
  }
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
104
  end
67
105
 
68
106
  # Where pdftk puts a single line's baseline: the ascent box centered in
@@ -84,72 +122,90 @@ module Acrofill
84
122
  align.is_a?(Integer) ? align : 0
85
123
  end
86
124
 
87
- def line_x(text, font, size, width, align)
88
- text_width = Metrics.string_width(text, font.widths, size)
89
- case align
90
- when 1 then [(width - text_width) / 2.0, PADDING].max
91
- when 2 then [width - PADDING - text_width, PADDING].max
125
+ # +text+ is already in the font's own codes. Left alignment needs no
126
+ # measurement, so it does not pay for one.
127
+ def line_x(text, font, size, box)
128
+ case box.align
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
92
131
  else PADDING
93
132
  end
94
133
  end
95
134
 
135
+ # A comb field divides its box into /MaxLen equal cells and centers one
136
+ # character in each; /Q picks the run of cells the value occupies. Each
137
+ # glyph is positioned absolutely, the way pdftk writes it.
138
+ def comb_body(text, font, size, box, cells)
139
+ cell = box.width / cells.to_f
140
+ chars = text.chars
141
+ first = case box.align
142
+ when 1 then [(cells - chars.size) / 2, 0].max
143
+ when 2 then [cells - chars.size, 0].max
144
+ else 0
145
+ end
146
+ ty = fmt(baseline(box.height, font, size))
147
+ chars.each_with_index.map do |char, index|
148
+ x = ((first + index + 0.5) * cell) - (font.width_of(char, size) / 2.0)
149
+ "1 0 0 1 #{fmt(x)} #{ty} Tm\n(#{escape_literal(char)}) Tj\n"
150
+ end.join
151
+ end
152
+
96
153
  # Greedy word wrap, top-down, honouring explicit line breaks. Lines
97
154
  # that would fall below the box are clipped by the BBox.
98
- def multiline_body(value, font, size, width, height, align)
99
- max_width = width - (2 * PADDING)
155
+ def multiline_body(value, font, size, box)
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.
100
161
  lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
101
162
  wrap_line(printable_text(paragraph), font, size, max_width)
102
163
  end
103
164
 
104
165
  # pdftk spaces rows by the font's FontBBox extent and drops the first
105
- # 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.
106
169
  leading = font.line_height(size)
107
- first_y = height - font.top(size) + TOP_OFFSET
170
+ first_y = [box.height - font.top(size) + TOP_OFFSET, 0.0].max
108
171
  body = "#{fmt(leading)} TL\n"
109
172
  previous_x = 0.0
110
173
  lines.each_with_index do |line, index|
111
- x = line_x(line, font, size, width, align)
174
+ encoded = font.encode(line)
175
+ x = line_x(encoded, font, size, box)
112
176
  body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
113
- body << "(#{escape_literal(line)}) Tj\nT*\n"
177
+ body << "(#{escape_literal(encoded)}) Tj\nT*\n"
114
178
  previous_x = x
115
179
  end
116
180
  body
117
181
  end
118
182
 
119
- # Greedy wrap. Line and space widths are accumulated incrementally so
120
- # 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).
121
186
  def wrap_line(text, font, size, max_width)
122
- space = Metrics.string_width(' ', font.widths, size)
123
- lines = ['']
124
- so_far = [0.0]
187
+ space = measure(' ', font, size)
188
+ lines = [+'']
189
+ width = 0.0
125
190
  text.split.each do |word|
126
- word_width = Metrics.string_width(word, font.widths, size)
191
+ word_width = measure(word, font, size)
127
192
  if lines.last.empty?
128
- lines[-1] = word
129
- so_far[-1] = word_width
130
- elsif so_far.last + space + word_width <= max_width
131
- lines[-1] = "#{lines.last} #{word}"
132
- 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
133
198
  else
134
- lines << word
135
- so_far << word_width
199
+ lines << +word
200
+ width = word_width
136
201
  end
137
202
  end
138
203
  lines
139
204
  end
140
205
 
141
- # Array entries may legally be indirect objects, so each corner is
142
- # dereferenced; a rect that is not four numbers is unusable.
143
- def normalized_rect(rect)
144
- rect = @doc.deref(rect)
145
- return nil unless rect.is_a?(Array) && rect.size == 4
146
-
147
- nums = rect.map { |n| @doc.deref(n) }
148
- return nil unless nums.all?(Numeric)
149
-
150
- xs = [nums[0].to_f, nums[2].to_f].sort
151
- ys = [nums[1].to_f, nums[3].to_f].sort
152
- [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)
153
209
  end
154
210
 
155
211
  # /DA is e.g. "/Helv 8 Tf 0 g": font + size around Tf, plus a colour.
@@ -166,9 +222,21 @@ module Acrofill
166
222
  return ['Helv', 0.0, '0 g'] unless tf && tf >= 2
167
223
 
168
224
  font = sanitize_font(tokens[tf - 2].to_s.delete_prefix('/'))
169
- size = tokens[tf - 1].to_f
170
- color = safe_color_ops(tokens[0...(tf - 2)] + tokens[(tf + 1)..])
171
- [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
172
240
  end
173
241
 
174
242
  # Only a name made of PDF-regular characters may be written as /Name.
@@ -178,28 +246,38 @@ module Acrofill
178
246
 
179
247
  # Keep only well-formed colour-setting operators (g / rg / k) with the
180
248
  # right count of numeric operands immediately before them; drop anything
181
- # else the /DA might carry.
182
- def safe_color_ops(tokens)
183
- ops = []
184
- 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|
185
258
  arity = COLOR_OP_ARITY[tok]
186
- 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
187
262
 
188
- operands = tokens[[i - arity, 0].max...i]
189
- 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) }
190
265
 
191
- ops << "#{operands.join(' ')} #{tok}"
266
+ "#{operands.join(' ')} #{tok}"
192
267
  end
193
- ops.empty? ? '0 g' : ops.join(' ')
194
268
  end
195
269
 
196
270
  # Fixed sizes that overflow the box are scaled down so the whole value
197
271
  # stays visible (Acrobat-style best-fit; pdftk would clip instead).
198
272
  def shrink_to_fit(text, font, size, width)
199
273
  max_width = width - (2 * PADDING)
200
- text_width = Metrics.string_width(text, font.widths, size)
201
- size *= max_width / text_width if text_width > max_width && text_width.positive?
202
- 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)
203
281
  end
204
282
 
205
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
@@ -6,6 +6,8 @@ module Acrofill
6
6
  # comes from the template, so each one is validated before use.
7
7
  class Flattener
8
8
  HIDDEN_FLAG = 2
9
+ NOVIEW_FLAG = 32
10
+ INVISIBLE = HIDDEN_FLAG | NOVIEW_FLAG
9
11
 
10
12
  def initialize(doc)
11
13
  @doc = doc
@@ -29,22 +31,27 @@ module Acrofill
29
31
  def flatten_page(page)
30
32
  annot_refs = @doc.deref(page[:Annots])
31
33
  annot_refs = [] unless annot_refs.is_a?(Array)
32
- annots = annot_refs.map { |a| [a, @doc.deref(a)] }
33
- 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 }
34
43
  return if widgets.empty?
35
44
 
36
- stamps = []
37
- widgets.each do |_ref, widget|
38
- stamp = stamp_operations(page, widget)
39
- stamps << stamp if stamp
40
- end
45
+ @pending = {}
46
+ stamps = widgets.filter_map { |_ref, widget| stamp_operations(widget) }
47
+ install_xobjects(page, @pending) unless @pending.empty?
41
48
 
42
49
  unless stamps.empty?
43
- wrap = ->(bytes) { @doc.add(StreamObject.new({}, bytes.b)) }
44
50
  derefed = @doc.deref(page[:Contents])
45
51
  contents = (derefed.is_a?(Array) ? derefed : [page[:Contents]]).compact
46
- contents = contents.map { |stream| @doc.ref_for(stream) }
47
- 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")]
48
55
  end
49
56
 
50
57
  remaining = others.map(&:first)
@@ -55,21 +62,34 @@ module Acrofill
55
62
  end
56
63
  end
57
64
 
65
+ def content_stream(bytes)
66
+ @doc.add(StreamObject.new({}, bytes.b))
67
+ end
68
+
58
69
  # Returns content-stream operations placing the widget's normal
59
70
  # appearance onto the page, or nil when there is nothing to draw.
60
71
  # Implements the appearance-box algorithm of PDF 32000 §12.5.5: the
61
72
  # form's /Matrix is applied to its BBox, and the resulting extent is
62
73
  # mapped onto the annotation rectangle.
63
- def stamp_operations(page, widget)
64
- return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
74
+ def stamp_operations(widget)
75
+ # Flattening produces the document as it is displayed, so a widget the
76
+ # viewer would not show is dropped rather than burned in: /F Hidden,
77
+ # and NoView, which means "not on screen" (PDF 32000 §12.5.3). pdftk
78
+ # stamps both, but stamping Hidden makes values the template author
79
+ # concealed permanently visible, so parity loses here.
80
+ return nil if annotation_flags(widget).anybits?(INVISIBLE)
65
81
 
66
82
  ap_ref = normal_appearance(widget)
67
83
  xobject = @doc.deref(ap_ref)
68
- dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
69
- return nil unless dict.is_a?(Hash)
70
-
71
- bbox = normalize_box(@doc.deref(dict[:BBox]))
72
- 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))
73
93
  return nil unless bbox && rect
74
94
 
75
95
  # Appearance streams are form XObjects, but /Type and /Subtype are
@@ -81,14 +101,31 @@ module Acrofill
81
101
  bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
82
102
  bw = bx1 - bx0
83
103
  bh = by1 - by0
84
- 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)
85
107
 
86
108
  sx = (urx - llx) / bw
87
109
  sy = (ury - lly) / bh
88
- name = register_xobject(page, ap_ref)
89
110
  matrix = [sx, 0, 0, sy, llx - (bx0 * sx), lly - (by0 * sy)]
90
- ops = matrix.map { |n| Serializer.format_number(n.to_f) }
91
- "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?
92
129
  end
93
130
 
94
131
  # Bounding box of the (already normalized) BBox corners after the
@@ -96,8 +133,10 @@ module Acrofill
96
133
  def transformed_bbox(bbox, matrix)
97
134
  x0, y0, x1, y1 = bbox
98
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.
99
138
  return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
100
- matrix.all?(Numeric)
139
+ matrix.all? { |m| m.is_a?(Numeric) && m.to_f.finite? }
101
140
 
102
141
  a, b, c, d, e, f = matrix.map(&:to_f)
103
142
  xs = []
@@ -106,6 +145,11 @@ module Acrofill
106
145
  xs << ((a * x) + (c * y) + e)
107
146
  ys << ((b * x) + (d * y) + f)
108
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
+
109
153
  [xs.min, ys.min, xs.max, ys.max]
110
154
  end
111
155
 
@@ -125,35 +169,27 @@ module Acrofill
125
169
  normal
126
170
  end
127
171
 
128
- # Derefs each element (array entries may legally be indirect objects)
129
- # and returns [llx, lly, urx, ury], or nil when the box is not four
130
- # numbers.
131
- def normalize_box(box)
132
- return nil unless box.is_a?(Array) && box.size == 4
133
-
134
- nums = box.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
139
- [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
140
179
  end
141
180
 
142
181
  # A page's /Resources (and its /XObject subdictionary) are template
143
182
  # data: anything that is not a dictionary is replaced rather than
144
- # indexed, which would raise TypeError on an Array or a stream.
145
- 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)
146
187
  resources = @doc.deref(page[:Resources]) || @doc.deref(@doc.inherited_value(page, :Resources))
147
188
  resources = resources.is_a?(Hash) ? resources.dup : {}
148
189
  xobjects = @doc.deref(resources[:XObject])
149
190
  xobjects = xobjects.is_a?(Hash) ? xobjects.dup : {}
150
-
151
- @stamp_counter += 1
152
- name = :"AcrofillAP#{@stamp_counter}"
153
- xobjects[name] = @doc.ref_for(ap_ref)
154
- resources[:XObject] = xobjects
191
+ resources[:XObject] = xobjects.merge(pending)
155
192
  page[:Resources] = resources
156
- name
157
193
  end
158
194
  end
159
195
  end
@@ -15,6 +15,7 @@ module Acrofill
15
15
  # widths /Widths + /FirstChar, else the standard-14 table
16
16
  # ascender /FontDescriptor /Ascent, else standard-14 AFM, else 800
17
17
  # FontBBox /FontDescriptor /FontBBox, else standard-14 AFM, else 900/-200
18
+ # codes /Encoding /Differences remap the value's bytes
18
19
  #
19
20
  # The standard-14 fallbacks apply only to a BaseFont that literally names
20
21
  # one of the fourteen; a face merely *resembling* one (ArialMT and friends)
@@ -40,7 +41,7 @@ module Acrofill
40
41
  # Metrics::Font for the font a /DA string names.
41
42
  def metrics(resource_name)
42
43
  key = resource_name.to_sym
43
- @metrics[key] ||= build(@doc.deref(entry(key)))
44
+ @metrics[key] ||= build(@doc.deref(dr_fonts[key]))
44
45
  end
45
46
 
46
47
  # Indirect reference to that font, for the appearance /Resources. Fonts
@@ -51,8 +52,13 @@ module Acrofill
51
52
  key = resource_name.to_sym
52
53
  @references[key] ||=
53
54
  begin
54
- found = entry(key)
55
- 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
56
62
  end
57
63
  end
58
64
 
@@ -67,10 +73,6 @@ module Acrofill
67
73
  end
68
74
  end
69
75
 
70
- def entry(key)
71
- dr_fonts[key]
72
- end
73
-
74
76
  def fallback
75
77
  @fallback ||=
76
78
  @doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
@@ -82,8 +84,33 @@ module Acrofill
82
84
  name = base_font_name(dict)
83
85
  standard = Metrics.standard_font(name)
84
86
  ascender, descender, top, bottom = vertical(dict, standard)
85
- Metrics::Font.new(widths(dict) || Metrics.widths_for(name), ascender, descender, top, bottom)
86
- .freeze
87
+ Metrics.build_font(widths(dict) || Metrics.widths_for(name),
88
+ ascender, descender, top, bottom,
89
+ Metrics.remap_for(differences(dict)))
90
+ end
91
+
92
+ # /Differences is a flat array where an integer restarts the code
93
+ # counter and each following name takes the next code (PDF 32000
94
+ # §9.6.6.1).
95
+ def differences(dict)
96
+ encoding = @doc.deref(dict[:Encoding])
97
+ return {} unless encoding.is_a?(Hash)
98
+
99
+ entries = @doc.deref(encoding[:Differences])
100
+ return {} unless entries.is_a?(Array)
101
+
102
+ code = nil
103
+ entries.each_with_object({}) do |raw, result|
104
+ item = @doc.deref(raw)
105
+ case item
106
+ when Integer then code = item
107
+ when Symbol
108
+ next unless code&.between?(0, 255)
109
+
110
+ result[code] = item # a glyph name, matching Metrics::WIN_ANSI_GLYPHS
111
+ code += 1
112
+ end
113
+ end
87
114
  end
88
115
 
89
116
  def base_font_name(dict)
@@ -113,11 +140,11 @@ module Acrofill
113
140
 
114
141
  def vertical(dict, standard)
115
142
  descriptor = @doc.deref(dict[:FontDescriptor])
116
- descriptor = nil unless descriptor.is_a?(Hash)
143
+ descriptor = {} unless descriptor.is_a?(Hash)
117
144
  bottom, top = font_bbox(descriptor)
118
145
  [
119
- (descriptor && number(descriptor[:Ascent])) || standard&.ascender || DEFAULT_ASCENDER,
120
- (descriptor && number(descriptor[:Descent])) || standard&.descender || DEFAULT_DESCENDER,
146
+ number(descriptor[:Ascent]) || standard&.ascender || DEFAULT_ASCENDER,
147
+ number(descriptor[:Descent]) || standard&.descender || DEFAULT_DESCENDER,
121
148
  top || standard&.bbox_top || DEFAULT_BBOX_TOP,
122
149
  bottom || standard&.bbox_bottom || DEFAULT_BBOX_BOTTOM
123
150
  ]
@@ -125,7 +152,7 @@ module Acrofill
125
152
 
126
153
  # [lower y, upper y] of /FontBBox, or [nil, nil] when it is unusable.
127
154
  def font_bbox(descriptor)
128
- box = descriptor && @doc.deref(descriptor[:FontBBox])
155
+ box = @doc.deref(descriptor[:FontBBox])
129
156
  return [nil, nil] unless box.is_a?(Array) && box.size == 4
130
157
 
131
158
  [number(box[1]), number(box[3])]
data/lib/acrofill/form.rb CHANGED
@@ -6,6 +6,12 @@ module Acrofill
6
6
  class Form
7
7
  MULTILINE_FLAG = 1 << 12
8
8
  PUSHBUTTON_FLAG = 1 << 16
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
9
15
 
10
16
  Field = Struct.new(:name, :type, :value, :states, keyword_init: true)
11
17
 
@@ -43,11 +49,17 @@ module Acrofill
43
49
  groups = @fields.fetch(name, [])
44
50
  return false if groups.empty?
45
51
 
46
- case field_type(groups.first[:node])
47
- when :Btn then fill_button(groups, value)
48
- when :Tx, :Ch, nil then fill_text_groups(groups, value)
49
- else false # signatures and unknown types are left untouched
50
- 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?
51
63
  end
52
64
 
53
65
  # Stamps every visible widget appearance into its page's content and
@@ -86,10 +98,12 @@ module Acrofill
86
98
  node[:V] = pdf_text_string(value)
87
99
  node.delete(:RV)
88
100
  node.delete(:I)
89
- multiline = field_flags(node).anybits?(MULTILINE_FLAG)
101
+ flags = field_flags(node)
102
+ multiline = flags.anybits?(MULTILINE_FLAG)
103
+ comb = comb_cells(node, flags, multiline)
90
104
  group[:widgets].each do |widget|
91
105
  widget.delete(:AS)
92
- ap_ref = @appearance.build(node, widget, value, multiline: multiline) unless value.empty?
106
+ ap_ref = @appearance.build(node, widget, value, multiline:, comb:) unless value.empty?
93
107
  # An unusable geometry yields no appearance; dropping /AP is still
94
108
  # required, or the widget would keep rendering the *previous* value
95
109
  # while /V already holds the new one.
@@ -102,6 +116,16 @@ module Acrofill
102
116
  true
103
117
  end
104
118
 
119
+ # How many cells a comb field lays its value out in, or nil when the
120
+ # field is not a comb. The flag only means anything alongside /MaxLen
121
+ # and on a single-line field (PDF 32000 §12.7.4.3).
122
+ def comb_cells(node, flags, multiline)
123
+ return nil if multiline || !flags.anybits?(COMB_FLAG)
124
+
125
+ cells = @doc.deref(@doc.inherited_value(node, :MaxLen))
126
+ cells.is_a?(Integer) && cells.positive? ? cells : nil
127
+ end
128
+
105
129
  # Checkboxes and radio groups carry per-state appearance streams, so
106
130
  # filling only selects a state: /V on the field, /AS on each widget.
107
131
  # The value may be a state name ("Yes"), or anything non-empty when the
@@ -118,7 +142,7 @@ module Acrofill
118
142
  state =
119
143
  if named
120
144
  named
121
- elsif ['', 'Off', 'false', 'no'].include?(value)
145
+ elsif OFF_VALUES.include?(value.downcase)
122
146
  :Off
123
147
  elsif states.size == 1
124
148
  states.first
@@ -214,7 +238,15 @@ module Acrofill
214
238
  raw.byteslice(2..).force_encoding('UTF-16BE')
215
239
  .encode('UTF-8', invalid: :replace, undef: :replace)
216
240
  else
217
- 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)
218
250
  end
219
251
  end
220
252
 
@@ -16,6 +16,9 @@ module Acrofill
16
16
  FIRST_CODE = 32
17
17
  LAST_CODE = 255
18
18
  WINDOWS_1252 = Encoding::Windows_1252
19
+ # Encodings whose bytes are already the codes the font is drawn with.
20
+ BYTE_ENCODINGS = [Encoding::Windows_1252, Encoding::BINARY].freeze
21
+ IDENTITY = Array.new(256) { |code| code }.freeze
19
22
 
20
23
  # The standard-14 text cuts, indexed by bold + 2 * italic per family.
21
24
  CUTS = {
@@ -26,8 +29,12 @@ module Acrofill
26
29
  # Family and weight hints for BaseFont names outside the standard 14.
27
30
  SERIF = /times|roman|serif|georgia|garamond/
28
31
  SANS = /sans/
29
- FIXED = /courier|mono/
30
- 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/
31
38
  ITALIC = /italic|oblique/
32
39
 
33
40
  TABLES = {
@@ -158,8 +165,55 @@ module Acrofill
158
165
  'Courier-BoldOblique' => [629, -157, 801, -250].freeze
159
166
  }.freeze
160
167
 
161
- # Everything the appearance code needs about one resolved face.
162
- Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom) do
168
+ # WinAnsiEncoding glyph names, index = code - 32. Used to re-encode a
169
+ # value for a font whose /Encoding remaps codes: the value is turned
170
+ # into glyph names, then into the codes *this* font draws them at.
171
+ WIN_ANSI_GLYPHS = [
172
+ :space, :exclam, :quotedbl, :numbersign, :dollar, :percent, :ampersand, :quotesingle,
173
+ :parenleft, :parenright, :asterisk, :plus, :comma, :hyphen, :period, :slash, :zero,
174
+ :one, :two, :three, :four, :five, :six, :seven, :eight, :nine, :colon, :semicolon,
175
+ :less, :equal, :greater, :question, :at, :A, :B, :C, :D, :E, :F, :G, :H, :I, :J, :K,
176
+ :L, :M, :N, :O, :P, :Q, :R, :S, :T, :U, :V, :W, :X, :Y, :Z, :bracketleft, :backslash,
177
+ :bracketright, :asciicircum, :underscore, :grave, :a, :b, :c, :d, :e, :f, :g, :h, :i,
178
+ :j, :k, :l, :m, :n, :o, :p, :q, :r, :s, :t, :u, :v, :w, :x, :y, :z, :braceleft, :bar,
179
+ :braceright, :asciitilde, :controlDEL, :Euro, nil, :quotesinglbase, :florin,
180
+ :quotedblbase, :ellipsis, :dagger, :daggerdbl, :circumflex, :perthousand, :Scaron,
181
+ :guilsinglleft, :OE, nil, :Zcaron, nil, nil, :quoteleft, :quoteright, :quotedblleft,
182
+ :quotedblright, :bullet, :endash, :emdash, :tilde, :trademark, :scaron,
183
+ :guilsinglright, :oe, nil, :zcaron, :Ydieresis, :space, :exclamdown, :cent, :sterling,
184
+ :currency, :yen, :brokenbar, :section, :dieresis, :copyright, :ordfeminine,
185
+ :guillemotleft, :logicalnot, :hyphen, :registered, :macron, :degree, :plusminus,
186
+ :twosuperior, :threesuperior, :acute, :mu, :paragraph, :periodcentered, :cedilla,
187
+ :onesuperior, :ordmasculine, :guillemotright, :onequarter, :onehalf, :threequarters,
188
+ :questiondown, :Agrave, :Aacute, :Acircumflex, :Atilde, :Adieresis, :Aring, :AE,
189
+ :Ccedilla, :Egrave, :Eacute, :Ecircumflex, :Edieresis, :Igrave, :Iacute, :Icircumflex,
190
+ :Idieresis, :Eth, :Ntilde, :Ograve, :Oacute, :Ocircumflex, :Otilde, :Odieresis,
191
+ :multiply, :Oslash, :Ugrave, :Uacute, :Ucircumflex, :Udieresis, :Yacute, :Thorn,
192
+ :germandbls, :agrave, :aacute, :acircumflex, :atilde, :adieresis, :aring, :ae,
193
+ :ccedilla, :egrave, :eacute, :ecircumflex, :edieresis, :igrave, :iacute, :icircumflex,
194
+ :idieresis, :eth, :ntilde, :ograve, :oacute, :ocircumflex, :otilde, :odieresis,
195
+ :divide, :oslash, :ugrave, :uacute, :ucircumflex, :udieresis, :yacute, :thorn,
196
+ :ydieresis
197
+ ].freeze
198
+
199
+ # Everything the appearance code needs about one resolved face. +remap+
200
+ # is a 256-entry code translation table, or nil when the font draws
201
+ # WinAnsi codes as they are.
202
+ Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom, :remap,
203
+ :code_widths) do
204
+ # Windows-1252 text as the byte codes this font draws it with.
205
+ def encode(text)
206
+ return text unless remap
207
+
208
+ text.b.each_byte.map { |code| remap[code] }.pack('C*')
209
+ end
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
+
163
217
  def ascent(size) = ascender * size / 1000.0
164
218
 
165
219
  def descent(size) = -descender * size / 1000.0
@@ -170,21 +224,79 @@ module Acrofill
170
224
  def line_height(size) = (bbox_top - bbox_bottom) * size / 1000.0
171
225
  end
172
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
+
173
249
  FONTS = VERTICAL.to_h do |name, (asc, desc, top, bottom)|
174
- [name, Font.new(WIDTHS[name], asc, desc, top, bottom).freeze]
250
+ [name, build_font(WIDTHS[name], asc, desc, top, bottom)]
175
251
  end.freeze
176
252
 
177
- # Width of +str+ at +size+ points. +widths+ is a table from #widths_for,
178
- # 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).
179
256
  def self.string_width(str, widths, size)
180
- widths = widths_for(widths) unless widths.is_a?(Array)
181
- str = to_win_ansi(str)
182
- units = str.each_byte.sum do |code|
183
- (code >= FIRST_CODE && widths[code - FIRST_CODE]) || DEFAULT_WIDTH
184
- 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] }
185
259
  units * size / 1000.0
186
260
  end
187
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
+
188
300
  # Metrics for a BaseFont name. Standard-14 names resolve directly;
189
301
  # anything else (ArialMT, TimesNewRomanPS-BoldMT, a template's embedded
190
302
  # face) is classified by family and weight, which is far closer than
@@ -225,14 +337,15 @@ module Acrofill
225
337
  (BOLD.match?(lower) ? 1 : 0) + (ITALIC.match?(lower) ? 2 : 0)
226
338
  end
227
339
 
228
- # Appearance text is already Windows-1252; anything else is converted so
229
- # that measuring and rendering agree byte for byte.
340
+ # Appearance text is already Windows-1252, or raw font codes after
341
+ # #encode; anything else is converted so that measuring and rendering
342
+ # agree byte for byte.
230
343
  def self.to_win_ansi(str)
231
- return str if str.encoding == WINDOWS_1252
344
+ return str if BYTE_ENCODINGS.include?(str.encoding)
232
345
 
233
346
  str.encode(WINDOWS_1252, invalid: :replace, undef: :replace, replace: '?')
234
347
  end
235
348
 
236
- private_class_method :serif?, :weight_index, :to_win_ansi
349
+ private_class_method :serif?, :weight_index, :to_win_ansi, :drawn_at
237
350
  end
238
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.3.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.3.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'