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 +4 -4
- data/CHANGELOG.md +75 -1
- data/lib/acrofill/appearance.rb +103 -60
- data/lib/acrofill/document.rb +21 -0
- data/lib/acrofill/filler.rb +26 -4
- data/lib/acrofill/flattener.rb +70 -41
- data/lib/acrofill/fonts.rb +15 -38
- data/lib/acrofill/form.rb +27 -14
- data/lib/acrofill/metrics.rb +82 -12
- data/lib/acrofill/template.rb +22 -3
- data/lib/acrofill/version.rb +1 -1
- data/lib/acrofill.rb +3 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2004f19b67792da81c96fb674f6659f9a8da74929334c73a7a46705337cf3545
|
|
4
|
+
data.tar.gz: 427e766a9775dfdbc5d6651c993418b0bf6d29694bc9462cae69aa719e242532
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/lib/acrofill/appearance.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
50
|
-
[
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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.
|
|
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 -
|
|
108
|
-
when 2 then [box.width - PADDING -
|
|
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) - (
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
155
|
-
#
|
|
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 =
|
|
158
|
-
lines = ['']
|
|
159
|
-
|
|
187
|
+
space = measure(' ', font, size)
|
|
188
|
+
lines = [+'']
|
|
189
|
+
width = 0.0
|
|
160
190
|
text.split.each do |word|
|
|
161
|
-
word_width =
|
|
191
|
+
word_width = measure(word, font, size)
|
|
162
192
|
if lines.last.empty?
|
|
163
|
-
lines[-1] = word
|
|
164
|
-
|
|
165
|
-
elsif
|
|
166
|
-
lines
|
|
167
|
-
|
|
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
|
-
|
|
199
|
+
lines << +word
|
|
200
|
+
width = word_width
|
|
171
201
|
end
|
|
172
202
|
end
|
|
173
203
|
lines
|
|
174
204
|
end
|
|
175
205
|
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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[
|
|
224
|
-
next unless operands.
|
|
263
|
+
operands = tokens[i - arity, arity]
|
|
264
|
+
next unless operands.all? { |o| NUMERIC_OPERAND.match?(o) }
|
|
225
265
|
|
|
226
|
-
|
|
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 =
|
|
236
|
-
size
|
|
237
|
-
|
|
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)
|
data/lib/acrofill/document.rb
CHANGED
|
@@ -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)
|
data/lib/acrofill/filler.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
39
|
-
|
|
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
|
data/lib/acrofill/flattener.rb
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
widgets.
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
page[:Contents] = [
|
|
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(
|
|
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
|
|
76
|
-
return nil unless dict
|
|
77
|
-
|
|
78
|
-
bbox =
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
data/lib/acrofill/fonts.rb
CHANGED
|
@@ -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(
|
|
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 =
|
|
57
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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 =
|
|
143
|
+
descriptor = {} unless descriptor.is_a?(Hash)
|
|
167
144
|
bottom, top = font_bbox(descriptor)
|
|
168
145
|
[
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
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
|
|
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')
|
|
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
|
|
data/lib/acrofill/metrics.rb
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
|
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,
|
|
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
|
|
220
|
-
# or a BaseFont name (resolved here, at
|
|
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 =
|
|
223
|
-
|
|
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
|
data/lib/acrofill/template.rb
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
19
|
-
@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
|
data/lib/acrofill/version.rb
CHANGED
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.
|
|
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'
|