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 +4 -4
- data/CHANGELOG.md +101 -1
- data/README.md +12 -7
- data/lib/acrofill/appearance.rb +161 -83
- data/lib/acrofill/document.rb +21 -0
- data/lib/acrofill/filler.rb +26 -4
- data/lib/acrofill/flattener.rb +78 -42
- data/lib/acrofill/fonts.rb +40 -13
- data/lib/acrofill/form.rb +41 -9
- data/lib/acrofill/metrics.rb +129 -16
- 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,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.
|
|
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
|
-
|
|
95
|
-
where pdftk
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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;
|
|
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
|
|
data/lib/acrofill/appearance.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
38
|
+
size, body = draw(value, font, size, box, multiline: multiline, comb: comb)
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
when
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
120
|
-
#
|
|
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 =
|
|
123
|
-
lines = ['']
|
|
124
|
-
|
|
187
|
+
space = measure(' ', font, size)
|
|
188
|
+
lines = [+'']
|
|
189
|
+
width = 0.0
|
|
125
190
|
text.split.each do |word|
|
|
126
|
-
word_width =
|
|
191
|
+
word_width = measure(word, font, size)
|
|
127
192
|
if lines.last.empty?
|
|
128
|
-
lines[-1] = word
|
|
129
|
-
|
|
130
|
-
elsif
|
|
131
|
-
lines
|
|
132
|
-
|
|
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
|
-
|
|
199
|
+
lines << +word
|
|
200
|
+
width = word_width
|
|
136
201
|
end
|
|
137
202
|
end
|
|
138
203
|
lines
|
|
139
204
|
end
|
|
140
205
|
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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[
|
|
189
|
-
next unless operands.
|
|
263
|
+
operands = tokens[i - arity, arity]
|
|
264
|
+
next unless operands.all? { |o| NUMERIC_OPERAND.match?(o) }
|
|
190
265
|
|
|
191
|
-
|
|
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 =
|
|
201
|
-
size
|
|
202
|
-
|
|
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)
|
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
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
widgets.
|
|
38
|
-
|
|
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
|
-
|
|
47
|
-
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")]
|
|
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(
|
|
64
|
-
|
|
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
|
|
69
|
-
return nil unless dict
|
|
70
|
-
|
|
71
|
-
bbox =
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
data/lib/acrofill/fonts.rb
CHANGED
|
@@ -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(
|
|
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 =
|
|
55
|
-
|
|
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
|
|
86
|
-
|
|
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 =
|
|
143
|
+
descriptor = {} unless descriptor.is_a?(Hash)
|
|
117
144
|
bottom, top = font_bbox(descriptor)
|
|
118
145
|
[
|
|
119
|
-
|
|
120
|
-
|
|
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 =
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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')
|
|
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
|
|
data/lib/acrofill/metrics.rb
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
#
|
|
162
|
-
|
|
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,
|
|
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
|
|
178
|
-
# 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).
|
|
179
256
|
def self.string_width(str, widths, size)
|
|
180
|
-
widths =
|
|
181
|
-
|
|
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
|
|
229
|
-
# that measuring and rendering
|
|
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
|
|
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
|
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
|
+
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'
|