acrofill 0.1.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +194 -0
- data/lib/acrofill/appearance.rb +214 -0
- data/lib/acrofill/document.rb +130 -0
- data/lib/acrofill/filler.rb +51 -0
- data/lib/acrofill/form.rb +325 -0
- data/lib/acrofill/metrics.rb +29 -0
- data/lib/acrofill/serializer.rb +73 -0
- data/lib/acrofill/template.rb +35 -0
- data/lib/acrofill/version.rb +5 -0
- data/lib/acrofill/writer.rb +101 -0
- data/lib/acrofill.rb +32 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 28d2db0f5f96d10602a2c8dd994a9957665df1426cffdbc045b3860c675e504e
|
|
4
|
+
data.tar.gz: c683b238b9ff31d182ad316712e3982c3ffa6dd38a92fa6547efca3e784d02c4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fb49f9bb0fe9efd45665d92c09c845a4156695b457bd9a3fddc13e7496a1221a009732147958f2a753130ebd5dc2f78b569b0ad117bf10ffa3262ba85bd3464e
|
|
7
|
+
data.tar.gz: 492ac4103cfa6fb500c70a648a94f479cf7411eb2293d5b7fc29ab7753e90bcd3a52f244facd94732e237f410a0038a9ca2a0ed2e7321a6ae4cc907744a4c265
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stiig
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Acrofill
|
|
2
|
+
|
|
3
|
+
Pure-Ruby PDF form (AcroForm) filling and flattening. No pdftk, no Java,
|
|
4
|
+
no native extensions — drop it into any Ruby or Rails project and fill
|
|
5
|
+
PDF templates in-process.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
Acrofill.fill_form('template.pdf', 'out.pdf',
|
|
9
|
+
{ 'Full Name' => 'Jane Roe', 'agree' => 'Yes' },
|
|
10
|
+
flatten: true)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Parsing is delegated to [pdf-reader](https://github.com/yob/pdf-reader)
|
|
14
|
+
(MIT), which handles classic and cross-reference-stream PDFs, object
|
|
15
|
+
streams, and every standard filter. Acrofill adds the write side: field
|
|
16
|
+
value setting, appearance-stream generation, checkbox/radio state
|
|
17
|
+
selection, flattening, and a full document serializer.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
The usual Ruby answer to "fill this PDF form" is the `pdf-forms` gem,
|
|
22
|
+
which shells out to [pdftk](https://gitlab.com/pdftk-java/pdftk) — a
|
|
23
|
+
Java application you must install on every machine and container, and
|
|
24
|
+
pay a JVM start-up for on every call. The main in-process alternative,
|
|
25
|
+
HexaPDF, is AGPL/commercial. Acrofill is MIT-licensed and fills a form
|
|
26
|
+
in a few milliseconds in-process.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile
|
|
32
|
+
gem 'acrofill'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require 'acrofill'
|
|
39
|
+
|
|
40
|
+
# Discover fields in a template
|
|
41
|
+
Acrofill.field_names('form.pdf')
|
|
42
|
+
# => ["Full Name", "amount", "agree", "color", "notes"]
|
|
43
|
+
|
|
44
|
+
Acrofill.fields('form.pdf').map { |f| [f.name, f.type, f.states] }
|
|
45
|
+
# => [["Full Name", :Tx, nil], ["agree", :Btn, [:Yes]], ["color", :Btn, [:Red, :Blue]], ...]
|
|
46
|
+
|
|
47
|
+
# Fill: text fields take strings, checkboxes/radios take a state name
|
|
48
|
+
Acrofill.fill_form('form.pdf', 'out.pdf', {
|
|
49
|
+
'Full Name' => 'Jane Roe',
|
|
50
|
+
'agree' => 'Yes', # checkbox: state name, or anything truthy
|
|
51
|
+
'color' => 'Blue', # radio group: state name
|
|
52
|
+
'notes' => "line one\nline two" # multiline fields wrap automatically
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
# Flatten: burn values into page content and drop the interactive form
|
|
56
|
+
Acrofill.fill_form('form.pdf', 'out.pdf', data, flatten: true)
|
|
57
|
+
|
|
58
|
+
# Filling the same template many times (the typical server pattern)?
|
|
59
|
+
# Parse it once — each subsequent fill skips parsing entirely (~3-8x
|
|
60
|
+
# faster per fill) and works on its own copy, so it is thread-safe:
|
|
61
|
+
template = Acrofill::Template.new('form.pdf')
|
|
62
|
+
template.fill_form('out1.pdf', { 'Full Name' => 'Jane' }, flatten: true)
|
|
63
|
+
template.fill_form('out2.pdf', { 'Full Name' => 'John' }, flatten: true)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Drop-in for pdf-forms
|
|
67
|
+
|
|
68
|
+
`Acrofill.new` accepts (and ignores) a pdftk path, so most `PdfForms`
|
|
69
|
+
call sites work unchanged:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
filler = Acrofill.new # was: PdfForms.new('/usr/bin/pdftk')
|
|
73
|
+
filler.fill_form(tpl, out, data, flatten: true)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Unknown field names are silently ignored, matching pdftk.
|
|
77
|
+
|
|
78
|
+
## Performance
|
|
79
|
+
|
|
80
|
+
Because Acrofill runs in-process, it avoids the JVM (or C++ process)
|
|
81
|
+
start-up that dominates a `pdftk` subprocess call — the exact pattern a
|
|
82
|
+
web app hits when it fills one form per request. Filling **and
|
|
83
|
+
flattening** three real-world government claim forms, 50 iterations
|
|
84
|
+
each, on an Apple M-series laptop against `pdftk-java` 3.3.3:
|
|
85
|
+
|
|
86
|
+
| Form | acrofill | pdftk (java) | speedup |
|
|
87
|
+
|----------------------------|---------:|-------------:|--------:|
|
|
88
|
+
| form A (124 fields, 3 pp) | 49 ms | 192 ms | 3.9× |
|
|
89
|
+
| form B (53 fields, 2 pp) | 16 ms | 124 ms | 7.6× |
|
|
90
|
+
| form C (187 fields, 2 pp) | 47 ms | 154 ms | 3.3× |
|
|
91
|
+
| **weighted total** | | | **4.2×**|
|
|
92
|
+
|
|
93
|
+
Reproduce with your own templates — the benchmark discovers each form's
|
|
94
|
+
fields automatically:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
ruby benchmark/compare.rb path/to/*.pdf # ITERATIONS=50 by default
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Self-contained synthetic benchmark
|
|
101
|
+
|
|
102
|
+
`benchmark/synthetic.rb` needs no templates — it generates a form with a
|
|
103
|
+
configurable number of text fields and reports three variants. Because
|
|
104
|
+
`pdftk` (a CLI) has no server mode, the JVM boots on every call; to be
|
|
105
|
+
fair we also measure that start-up cost separately and subtract it, so
|
|
106
|
+
**pdftk warm** models the marginal PDF-processing cost with the runtime
|
|
107
|
+
already hot (a lower bound on any long-lived-pdftk setup):
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
$ FIELDS=30 ruby benchmark/synthetic.rb
|
|
111
|
+
variant ms/fill vs acrofill
|
|
112
|
+
acrofill (in-process) 3.4 1.0×
|
|
113
|
+
acrofill (cached template) 1.1 0.3×
|
|
114
|
+
pdftk cold (subprocess) 122.3 36.0×
|
|
115
|
+
pdftk warm (JVM excluded) 90.6 26.6×
|
|
116
|
+
(measured pdftk start-up overhead: ~32 ms/call)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Even discounting JVM start-up entirely, Acrofill is ~13–29× faster on
|
|
120
|
+
synthetic forms (the multiplier shrinks as field count grows: ~29× at 30
|
|
121
|
+
fields, ~13× at 80). The gap is largest on simple forms — exactly the
|
|
122
|
+
common case — and start-up is a real per-request cost for any `pdftk`-CLI
|
|
123
|
+
integration. The `pdftk warm` figure is a conservative estimate: `pdftk
|
|
124
|
+
--version` loads fewer classes than an actual fill, so true warm cost is
|
|
125
|
+
somewhat higher than shown.
|
|
126
|
+
|
|
127
|
+
Parsing dominates a one-shot fill (~86% of the time on a real 3-page
|
|
128
|
+
form), which is what `Acrofill::Template` eliminates: on the 124-field
|
|
129
|
+
form above a one-shot fill takes ~47 ms while a cached-template fill
|
|
130
|
+
takes ~6 ms — against pdftk's ~192 ms for the same job.
|
|
131
|
+
|
|
132
|
+
## Security
|
|
133
|
+
|
|
134
|
+
The PDF **template** is treated as untrusted input (the typical
|
|
135
|
+
"upload a PDF and fill it" case), and so are field names and values.
|
|
136
|
+
Acrofill is pure Ruby with no `eval`/`system`/native calls in its hot
|
|
137
|
+
path, so the worst an input can do is cause an exception — never memory
|
|
138
|
+
corruption or code execution. Specifically it defends against:
|
|
139
|
+
|
|
140
|
+
- **Cyclic and exponentially-shared object graphs** — the page tree and
|
|
141
|
+
field tree walkers carry visited-sets, so a `/Pages` or `/Kids` cycle,
|
|
142
|
+
or a "billion laughs" DAG, terminates in O(objects) instead of hanging
|
|
143
|
+
or overflowing the stack.
|
|
144
|
+
- **Content-stream injection via `/DA`** — the template's default
|
|
145
|
+
appearance string is sanitized: the font name must be a regular PDF
|
|
146
|
+
name and only well-formed colour operators (`g`/`rg`/`k`) are copied
|
|
147
|
+
into generated appearances. Field **values** are always written as
|
|
148
|
+
escaped literals / hex strings, never as operators.
|
|
149
|
+
- **Malformed scalars** — non-finite reals, deeply nested arrays, and
|
|
150
|
+
mistyped dictionaries are clamped or rejected rather than crashing the
|
|
151
|
+
writer.
|
|
152
|
+
- **Encrypted / unparseable input** — rejected up front; every failure
|
|
153
|
+
at the parse boundary surfaces as `Acrofill::Error`, so callers only
|
|
154
|
+
ever rescue one error type.
|
|
155
|
+
|
|
156
|
+
## Feature matrix
|
|
157
|
+
|
|
158
|
+
Supported:
|
|
159
|
+
|
|
160
|
+
- Text fields (`/Tx`) — hierarchical names (`parent.kid`), inherited
|
|
161
|
+
`/DA`, alignment via `/Q` (left/center/right), auto font size (`0 Tf`),
|
|
162
|
+
shrink-to-fit for overflowing values, multiline fields (`/Ff` bit 13)
|
|
163
|
+
with word wrapping, standard-14 width metrics
|
|
164
|
+
(Helvetica/Courier/Times).
|
|
165
|
+
- Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
|
|
166
|
+
using the template's own appearance states.
|
|
167
|
+
- Choice fields (`/Ch`) — value set and rendered like text.
|
|
168
|
+
- Flattening — every visible widget appearance is stamped into the page
|
|
169
|
+
content; widget annotations and the AcroForm dictionary are removed.
|
|
170
|
+
- Values in any encoding (stored as UTF-16BE when needed; appearances
|
|
171
|
+
render the Windows-1252 subset).
|
|
172
|
+
|
|
173
|
+
Not supported (rejected or ignored, never a hard crash):
|
|
174
|
+
|
|
175
|
+
- Encrypted documents (raise `Acrofill::Error`), XFA forms, digital
|
|
176
|
+
signatures, JavaScript actions.
|
|
177
|
+
- Push buttons; comb fields render as plain text; rich text (`/RV`) is
|
|
178
|
+
dropped on fill.
|
|
179
|
+
- Glyphs outside Windows-1252 in generated appearances (stored values
|
|
180
|
+
keep full Unicode; unrenderable glyphs appear as `?`).
|
|
181
|
+
|
|
182
|
+
## Development
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
bundle install
|
|
186
|
+
bundle exec rake spec
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The test suite generates its fixture PDFs from scratch — the repository
|
|
190
|
+
contains no binary files.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT. See LICENSE.txt.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# Builds the /AP /N appearance stream (a Form XObject) for a filled
|
|
5
|
+
# text-field widget, honouring the field's /DA string and /Q alignment.
|
|
6
|
+
class Appearance
|
|
7
|
+
PADDING = 2.0
|
|
8
|
+
ASCENT = 0.718 # Helvetica cap-height-ish ascent, em fractions
|
|
9
|
+
DESCENT = 0.207
|
|
10
|
+
# Colour-setting operators allowed in a /DA string, and their operand counts.
|
|
11
|
+
COLOR_OP_ARITY = { 'g' => 1, 'rg' => 3, 'k' => 4 }.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(doc, acroform)
|
|
14
|
+
@doc = doc
|
|
15
|
+
@acroform = acroform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns a Reference to the new appearance XObject, or nil when the
|
|
19
|
+
# widget geometry is unusable.
|
|
20
|
+
def build(field_node, widget, value, multiline: false)
|
|
21
|
+
rect = normalized_rect(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
|
|
22
|
+
return nil unless rect
|
|
23
|
+
|
|
24
|
+
width = rect[2] - rect[0]
|
|
25
|
+
height = rect[3] - rect[1]
|
|
26
|
+
return nil if width <= 0 || height <= 0
|
|
27
|
+
|
|
28
|
+
font_name, size, color_ops = parse_da(field_node)
|
|
29
|
+
base_font = base_font_for(font_name)
|
|
30
|
+
align = @doc.inherited_value(field_node, :Q) || @acroform[:Q] || 0
|
|
31
|
+
|
|
32
|
+
body =
|
|
33
|
+
if multiline
|
|
34
|
+
size = 12.0 if size <= 0
|
|
35
|
+
size = size.clamp(2.0, 144.0)
|
|
36
|
+
multiline_body(value, base_font, size, width, height, align)
|
|
37
|
+
else
|
|
38
|
+
text = printable_text(value)
|
|
39
|
+
size = [height * 0.66, 12.0].min if size.zero?
|
|
40
|
+
size = shrink_to_fit(text, base_font, size, width)
|
|
41
|
+
ty = [(height - (size * (ASCENT - DESCENT))) / 2.0, size * DESCENT].max
|
|
42
|
+
"#{fmt(line_x(text, base_font, size, width, align))} #{fmt(ty)} Td\n" \
|
|
43
|
+
"(#{escape_literal(text)}) Tj\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
content = +"/Tx BMC\nq\nBT\n"
|
|
47
|
+
content << "#{color_ops}\n" unless color_ops.empty?
|
|
48
|
+
content << "/#{font_name} #{fmt(size)} Tf\n"
|
|
49
|
+
content << body
|
|
50
|
+
content << "ET\nQ\nEMC\n"
|
|
51
|
+
|
|
52
|
+
dict = {
|
|
53
|
+
Type: :XObject,
|
|
54
|
+
Subtype: :Form,
|
|
55
|
+
FormType: 1,
|
|
56
|
+
BBox: [0, 0, width, height],
|
|
57
|
+
Resources: { Font: { font_name.to_sym => font_ref(font_name) } }
|
|
58
|
+
}
|
|
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
|
+
end
|
|
67
|
+
|
|
68
|
+
def line_x(text, base_font, size, width, align)
|
|
69
|
+
text_width = Metrics.string_width(text, base_font, size)
|
|
70
|
+
case align
|
|
71
|
+
when 1 then [(width - text_width) / 2.0, PADDING].max
|
|
72
|
+
when 2 then [width - PADDING - text_width, PADDING].max
|
|
73
|
+
else PADDING
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Greedy word wrap, top-down, honouring explicit line breaks. Lines
|
|
78
|
+
# that would fall below the box are clipped by the BBox.
|
|
79
|
+
def multiline_body(value, base_font, size, width, height, align)
|
|
80
|
+
max_width = width - (2 * PADDING)
|
|
81
|
+
lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
|
|
82
|
+
wrap_line(printable_text(paragraph), base_font, size, max_width)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
leading = size * 1.15
|
|
86
|
+
first_y = height - PADDING - (size * ASCENT)
|
|
87
|
+
body = "#{fmt(leading)} TL\n"
|
|
88
|
+
previous_x = 0.0
|
|
89
|
+
lines.each_with_index do |line, index|
|
|
90
|
+
x = line_x(line, base_font, size, width, align)
|
|
91
|
+
body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
|
|
92
|
+
body << "(#{escape_literal(line)}) Tj\nT*\n"
|
|
93
|
+
previous_x = x
|
|
94
|
+
end
|
|
95
|
+
body
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Greedy wrap. Line and space widths are accumulated incrementally so
|
|
99
|
+
# the cost is O(total characters), not O(words * line-length).
|
|
100
|
+
def wrap_line(text, base_font, size, max_width)
|
|
101
|
+
space = Metrics.string_width(' ', base_font, size)
|
|
102
|
+
lines = ['']
|
|
103
|
+
widths = [0.0]
|
|
104
|
+
text.split.each do |word|
|
|
105
|
+
word_width = Metrics.string_width(word, base_font, size)
|
|
106
|
+
if lines.last.empty?
|
|
107
|
+
lines[-1] = word
|
|
108
|
+
widths[-1] = word_width
|
|
109
|
+
elsif widths.last + space + word_width <= max_width
|
|
110
|
+
lines[-1] = "#{lines.last} #{word}"
|
|
111
|
+
widths[-1] += space + word_width
|
|
112
|
+
else
|
|
113
|
+
lines << word
|
|
114
|
+
widths << word_width
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
lines
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalized_rect(rect)
|
|
121
|
+
rect = @doc.deref(rect)
|
|
122
|
+
return nil unless rect.is_a?(Array) && rect.size == 4
|
|
123
|
+
|
|
124
|
+
xs = [rect[0].to_f, rect[2].to_f].sort
|
|
125
|
+
ys = [rect[1].to_f, rect[3].to_f].sort
|
|
126
|
+
[xs[0], ys[0], xs[1], ys[1]]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# /DA is e.g. "/Helv 8 Tf 0 g": font + size around Tf, plus a colour.
|
|
130
|
+
# The /DA string is template-controlled and copied into the generated
|
|
131
|
+
# appearance stream, so the font name and colour operators are both
|
|
132
|
+
# sanitized here — otherwise a crafted /DA could inject arbitrary
|
|
133
|
+
# content operators (e.g. rectangle fills) into the output.
|
|
134
|
+
def parse_da(field_node)
|
|
135
|
+
da = @doc.deref(@doc.inherited_value(field_node, :DA)) || @doc.deref(@acroform[:DA]) || '/Helv 0 Tf 0 g'
|
|
136
|
+
return ['Helv', 0.0, '0 g'] unless da.is_a?(String)
|
|
137
|
+
|
|
138
|
+
tokens = da.split
|
|
139
|
+
tf = tokens.index('Tf')
|
|
140
|
+
return ['Helv', 0.0, '0 g'] unless tf && tf >= 2
|
|
141
|
+
|
|
142
|
+
font = sanitize_font(tokens[tf - 2].to_s.delete_prefix('/'))
|
|
143
|
+
size = tokens[tf - 1].to_f
|
|
144
|
+
color = safe_color_ops(tokens[0...(tf - 2)] + tokens[(tf + 1)..])
|
|
145
|
+
[font, size, color]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Only a name made of PDF-regular characters may be written as /Name.
|
|
149
|
+
def sanitize_font(name)
|
|
150
|
+
Serializer::REGULAR_NAME.match?(name) ? name : 'Helv'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Keep only well-formed colour-setting operators (g / rg / k) with the
|
|
154
|
+
# right count of numeric operands immediately before them; drop anything
|
|
155
|
+
# else the /DA might carry.
|
|
156
|
+
def safe_color_ops(tokens)
|
|
157
|
+
ops = []
|
|
158
|
+
tokens.each_with_index do |tok, i|
|
|
159
|
+
arity = COLOR_OP_ARITY[tok]
|
|
160
|
+
next unless arity
|
|
161
|
+
|
|
162
|
+
operands = tokens[[i - arity, 0].max...i]
|
|
163
|
+
next unless operands.size == arity && operands.all? { |o| o =~ /\A-?\d*\.?\d+\z/ }
|
|
164
|
+
|
|
165
|
+
ops << "#{operands.join(' ')} #{tok}"
|
|
166
|
+
end
|
|
167
|
+
ops.empty? ? '0 g' : ops.join(' ')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Fixed sizes that overflow the box are scaled down so the whole value
|
|
171
|
+
# stays visible (Acrobat-style best-fit; pdftk would clip instead).
|
|
172
|
+
def shrink_to_fit(text, base_font, size, width)
|
|
173
|
+
max_width = width - (2 * PADDING)
|
|
174
|
+
text_width = Metrics.string_width(text, base_font, size)
|
|
175
|
+
size *= max_width / text_width if text_width > max_width && text_width.positive?
|
|
176
|
+
size.clamp(2.0, 144.0)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The font resource dictionary from /AcroForm /DR /Font, or {} when the
|
|
180
|
+
# template supplies a malformed (non-dictionary) /DR or /Font.
|
|
181
|
+
def dr_fonts
|
|
182
|
+
dr = @doc.deref(@acroform[:DR])
|
|
183
|
+
return {} unless dr.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
fonts = @doc.deref(dr[:Font])
|
|
186
|
+
fonts.is_a?(Hash) ? fonts : {}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def base_font_for(resource_name)
|
|
190
|
+
font = @doc.deref(dr_fonts[resource_name.to_sym])
|
|
191
|
+
base = font.is_a?(Hash) ? font[:BaseFont].to_s : ''
|
|
192
|
+
base.sub(/\A[A-Z]{6}\+/, '')
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def font_ref(resource_name)
|
|
196
|
+
entry = dr_fonts[resource_name.to_sym]
|
|
197
|
+
return @doc.ref_for(entry) if entry
|
|
198
|
+
|
|
199
|
+
# Font not present in /DR: register a plain Helvetica.
|
|
200
|
+
@doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def printable_text(value)
|
|
204
|
+
value.to_s.gsub(/[[:space:]]+/, ' ').strip
|
|
205
|
+
.encode('Windows-1252', invalid: :replace, undef: :replace, replace: '?')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def escape_literal(text)
|
|
209
|
+
text.b
|
|
210
|
+
.gsub(/[\\()]/) { |ch| "\\#{ch}" }
|
|
211
|
+
.gsub(/[\x00-\x1F]/) { |ch| format('\\%03o', ch.ord) }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pdf-reader'
|
|
4
|
+
|
|
5
|
+
module Acrofill
|
|
6
|
+
# A new stream object created by acrofill (appearance streams, stamped
|
|
7
|
+
# content). +raw+ holds the final, already-encoded stream bytes.
|
|
8
|
+
StreamObject = Struct.new(:dict, :raw)
|
|
9
|
+
|
|
10
|
+
# In-memory object store for a parsed PDF. All indirect objects are
|
|
11
|
+
# materialized once via pdf-reader (which transparently handles xref
|
|
12
|
+
# streams and object streams), then mutated in place before writing.
|
|
13
|
+
class Document
|
|
14
|
+
attr_reader :objects, :trailer
|
|
15
|
+
|
|
16
|
+
def initialize(path)
|
|
17
|
+
hash = load_object_hash(path)
|
|
18
|
+
raise Error, 'encrypted PDFs are not supported' if hash.trailer[:Encrypt]
|
|
19
|
+
|
|
20
|
+
@objects = {}
|
|
21
|
+
@max_oid = 0
|
|
22
|
+
hash.each do |ref, obj|
|
|
23
|
+
@objects[ref.id] = obj
|
|
24
|
+
@max_oid = ref.id if ref.id > @max_oid
|
|
25
|
+
end
|
|
26
|
+
@trailer = hash.trailer.slice(:Root, :Info, :ID)
|
|
27
|
+
raise Error, 'PDF has no document catalog' unless @trailer[:Root]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A serialized copy of the pristine object graph; see Template.
|
|
31
|
+
def snapshot
|
|
32
|
+
Marshal.dump([@objects, @trailer, @max_oid])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Rebuilds a Document from a snapshot produced by #snapshot. Restoring
|
|
36
|
+
# deep-copies every object, so mutations never leak between fills.
|
|
37
|
+
# Snapshots are an internal format: only feed this data produced by
|
|
38
|
+
# #snapshot in the same process (Marshal is not safe on foreign input).
|
|
39
|
+
def self.restore(snapshot)
|
|
40
|
+
doc = allocate
|
|
41
|
+
# Snapshots are produced by #snapshot in the same process and never
|
|
42
|
+
# accepted from external input, so Marshal here is not a deserialization
|
|
43
|
+
# boundary (see Template docs).
|
|
44
|
+
doc.send(:restore_state, *Marshal.load(snapshot)) # rubocop:disable Security/MarshalLoad
|
|
45
|
+
doc
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def restore_state(objects, trailer, max_oid)
|
|
51
|
+
@objects = objects
|
|
52
|
+
@trailer = trailer
|
|
53
|
+
@max_oid = max_oid
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The parse boundary for untrusted input: pdf-reader (and the decryptors
|
|
57
|
+
# it calls) can raise a wide assortment of errors on crafted files, so
|
|
58
|
+
# everything is normalized to a single Acrofill::Error type.
|
|
59
|
+
def load_object_hash(path)
|
|
60
|
+
PDF::Reader::ObjectHash.new(path)
|
|
61
|
+
rescue PDF::Reader::EncryptedPDFError => e
|
|
62
|
+
raise Error, "encrypted PDFs are not supported (#{e.message})"
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
raise Error, "could not parse PDF (#{e.class}: #{e.message})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
public
|
|
68
|
+
|
|
69
|
+
# Follows reference chains (ref -> ref -> value is legal PDF), with a
|
|
70
|
+
# hop cap so a reference cycle cannot loop forever.
|
|
71
|
+
def deref(obj)
|
|
72
|
+
hops = 0
|
|
73
|
+
while obj.is_a?(PDF::Reader::Reference)
|
|
74
|
+
return nil if (hops += 1) > 16
|
|
75
|
+
|
|
76
|
+
obj = @objects[obj.id]
|
|
77
|
+
end
|
|
78
|
+
obj
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Adds a new indirect object and returns a reference to it.
|
|
82
|
+
def add(obj)
|
|
83
|
+
@max_oid += 1
|
|
84
|
+
@objects[@max_oid] = obj
|
|
85
|
+
PDF::Reader::Reference.new(@max_oid, 0)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Wraps a direct object into an indirect one; passes references through.
|
|
89
|
+
def ref_for(obj)
|
|
90
|
+
obj.is_a?(PDF::Reader::Reference) ? obj : add(obj)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def root
|
|
94
|
+
deref(@trailer[:Root])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def each_page(node = root[:Pages], seen = {}, &block)
|
|
98
|
+
dict = deref(node)
|
|
99
|
+
return unless dict.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
# Guard against cyclic and shared (billion-laughs) /Pages trees: a
|
|
102
|
+
# node reachable more than once is walked only the first time.
|
|
103
|
+
if node.is_a?(PDF::Reader::Reference)
|
|
104
|
+
return if seen[node.id]
|
|
105
|
+
|
|
106
|
+
seen[node.id] = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
case dict[:Type]
|
|
110
|
+
when :Pages
|
|
111
|
+
(deref(dict[:Kids]) || []).each { |kid| each_page(kid, seen, &block) }
|
|
112
|
+
when :Page
|
|
113
|
+
yield dict
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Resolves an attribute inheritable through the /Parent chain
|
|
118
|
+
# (field attributes like /FT, /DA, /Q or page attributes).
|
|
119
|
+
def inherited_value(node, key)
|
|
120
|
+
seen = 0
|
|
121
|
+
while node.is_a?(Hash)
|
|
122
|
+
return node[key] if node.key?(key)
|
|
123
|
+
return nil if (seen += 1) > 64 # cycle guard
|
|
124
|
+
|
|
125
|
+
node = deref(node[:Parent])
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# Public entry point, signature-compatible with PdfForms#fill_form:
|
|
5
|
+
#
|
|
6
|
+
# Acrofill.new.fill_form(template, output, { 'Field' => 'value' }, flatten: true)
|
|
7
|
+
#
|
|
8
|
+
# Note: PdfForms-specific options (data_format:, utf8_fields:, ...) are
|
|
9
|
+
# accepted and ignored — acrofill needs no FDF and is always UTF-aware.
|
|
10
|
+
class Filler
|
|
11
|
+
# Accepts and ignores a pdftk path argument for drop-in compatibility.
|
|
12
|
+
def initialize(*); end
|
|
13
|
+
|
|
14
|
+
def fill_form(template, destination, data = {}, options = {})
|
|
15
|
+
apply(Document.new(template), destination, data, options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Field metadata (name, type, current value, checkbox/radio states)
|
|
19
|
+
# without modifying the document.
|
|
20
|
+
def fields(template)
|
|
21
|
+
Form.new(Document.new(template)).fields
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Fully-qualified names of all fillable fields.
|
|
25
|
+
def field_names(template)
|
|
26
|
+
fields(template).map(&:name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Shared fill pipeline, also driven by Template with a restored
|
|
30
|
+
# document. Values are normalized to valid UTF-8 up front so that a
|
|
31
|
+
# mis-encoded input degrades (replacement character) instead of
|
|
32
|
+
# leaking a raw Encoding error from deep inside the appearance code.
|
|
33
|
+
def apply(doc, destination, data, options)
|
|
34
|
+
form = Form.new(doc)
|
|
35
|
+
data.each do |name, value|
|
|
36
|
+
form.fill(normalize(name), normalize(value))
|
|
37
|
+
end
|
|
38
|
+
form.flatten! if options[:flatten] || options['flatten']
|
|
39
|
+
File.binwrite(destination, Writer.new(doc).render)
|
|
40
|
+
destination
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def normalize(value)
|
|
46
|
+
return '' if value.nil?
|
|
47
|
+
|
|
48
|
+
value.to_s.encode('UTF-8', invalid: :replace, undef: :replace).scrub
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# The interactive form of a document: field lookup by fully-qualified
|
|
5
|
+
# name, value filling with appearance regeneration, and flattening.
|
|
6
|
+
class Form
|
|
7
|
+
HIDDEN_FLAG = 2
|
|
8
|
+
MULTILINE_FLAG = 1 << 12
|
|
9
|
+
PUSHBUTTON_FLAG = 1 << 16
|
|
10
|
+
|
|
11
|
+
Field = Struct.new(:name, :type, :value, :states, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(doc)
|
|
14
|
+
@doc = doc
|
|
15
|
+
@acroform = doc.deref(doc.root[:AcroForm])
|
|
16
|
+
raise Error, 'document has no AcroForm' unless @acroform.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
@appearance = Appearance.new(doc, @acroform)
|
|
19
|
+
# name => array of { node:, widgets: } groups. Several field dicts may
|
|
20
|
+
# share one fully-qualified name (PDF 32000 §12.7.3.2 treats them as a
|
|
21
|
+
# single logical field, e.g. "SSN" repeated on every page) — filling
|
|
22
|
+
# must update all of them.
|
|
23
|
+
@fields = Hash.new { |h, k| h[k] = [] }
|
|
24
|
+
collect_fields(doc.deref(@acroform[:Fields]) || [], nil, {})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Metadata for every logical field, in document order.
|
|
28
|
+
def fields
|
|
29
|
+
@fields.map do |name, groups|
|
|
30
|
+
type = field_type(groups.first[:node])
|
|
31
|
+
Field.new(
|
|
32
|
+
name: name,
|
|
33
|
+
type: type,
|
|
34
|
+
value: decode_text_string(groups.first[:node][:V]),
|
|
35
|
+
states: type == :Btn ? on_states(all_widgets(groups)) : nil
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sets the field value and rebuilds widget appearances. Unknown names
|
|
41
|
+
# are ignored (returns false), matching pdftk's fill_form behaviour.
|
|
42
|
+
def fill(name, value)
|
|
43
|
+
groups = @fields.fetch(name, [])
|
|
44
|
+
return false if groups.empty?
|
|
45
|
+
|
|
46
|
+
case field_type(groups.first[:node])
|
|
47
|
+
when :Btn then fill_button(groups, value)
|
|
48
|
+
when :Tx, :Ch, nil then groups.each { |group| fill_text(group, value) }.any?
|
|
49
|
+
else false # signatures and unknown types are left untouched
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stamps every visible widget appearance into its page's content and
|
|
54
|
+
# removes the interactive layer, like pdftk's `output ... flatten`.
|
|
55
|
+
def flatten!
|
|
56
|
+
@doc.each_page { |page| flatten_page(page) }
|
|
57
|
+
@doc.root.delete(:AcroForm)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def all_widgets(groups)
|
|
63
|
+
groups.flat_map { |group| group[:widgets] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def field_type(node)
|
|
67
|
+
type = @doc.inherited_value(node, :FT)
|
|
68
|
+
type.is_a?(Symbol) ? type : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# /Ff is an integer bit set; coerce defensively since malformed input
|
|
72
|
+
# can supply a non-integer here (Array#to_i does not exist).
|
|
73
|
+
def field_flags(node)
|
|
74
|
+
flags = @doc.deref(@doc.inherited_value(node, :Ff))
|
|
75
|
+
flags.is_a?(Integer) ? flags : 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Annotation /F flags. Must be dereferenced before to_i: on a
|
|
79
|
+
# PDF::Reader::Reference, to_i returns the object *number*.
|
|
80
|
+
def annotation_flags(widget)
|
|
81
|
+
flags = @doc.deref(widget[:F])
|
|
82
|
+
flags.is_a?(Integer) ? flags : 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fill_text(group, value)
|
|
86
|
+
node = group[:node]
|
|
87
|
+
node[:V] = pdf_text_string(value)
|
|
88
|
+
node.delete(:RV)
|
|
89
|
+
node.delete(:I)
|
|
90
|
+
multiline = field_flags(node).anybits?(MULTILINE_FLAG)
|
|
91
|
+
group[:widgets].each do |widget|
|
|
92
|
+
widget.delete(:AS)
|
|
93
|
+
if value.empty?
|
|
94
|
+
widget.delete(:AP)
|
|
95
|
+
else
|
|
96
|
+
ap_ref = @appearance.build(node, widget, value, multiline: multiline)
|
|
97
|
+
widget[:AP] = { N: ap_ref } if ap_ref
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Checkboxes and radio groups carry per-state appearance streams, so
|
|
104
|
+
# filling only selects a state: /V on the field, /AS on each widget.
|
|
105
|
+
# The value may be a state name ("Yes"), or anything non-empty when the
|
|
106
|
+
# group has a single on state. Empty, "Off" or false-ish unchecks.
|
|
107
|
+
def fill_button(groups, value)
|
|
108
|
+
widgets = all_widgets(groups)
|
|
109
|
+
states = on_states(widgets)
|
|
110
|
+
# Match against the state names already present in the template
|
|
111
|
+
# rather than interning the untrusted value (avoids unbounded
|
|
112
|
+
# symbol creation from attacker-controlled field values).
|
|
113
|
+
named = states.find { |s| s.to_s == value }
|
|
114
|
+
state =
|
|
115
|
+
if ['', 'Off', 'false', 'no'].include?(value)
|
|
116
|
+
:Off
|
|
117
|
+
elsif named
|
|
118
|
+
named
|
|
119
|
+
elsif states.size == 1
|
|
120
|
+
states.first
|
|
121
|
+
else
|
|
122
|
+
return false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
filled = false
|
|
126
|
+
groups.each do |group|
|
|
127
|
+
node = group[:node]
|
|
128
|
+
next if field_flags(node).anybits?(PUSHBUTTON_FLAG)
|
|
129
|
+
|
|
130
|
+
node[:V] = state
|
|
131
|
+
group[:widgets].each do |widget|
|
|
132
|
+
widget[:AS] = widget_states(widget).include?(state) ? state : :Off
|
|
133
|
+
end
|
|
134
|
+
filled = true
|
|
135
|
+
end
|
|
136
|
+
filled
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def on_states(widgets)
|
|
140
|
+
widgets.flat_map { |widget| widget_states(widget) }.uniq - [:Off]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def widget_states(widget)
|
|
144
|
+
ap = @doc.deref(widget[:AP])
|
|
145
|
+
return [] unless ap.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
normal = @doc.deref(ap[:N])
|
|
148
|
+
normal.is_a?(Hash) && !normal.is_a?(PDF::Reader::Stream) ? normal.keys : []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Guard against cyclic and shared /Kids graphs: a field node reachable
|
|
152
|
+
# more than once is expanded only the first time.
|
|
153
|
+
def visited?(ref, seen)
|
|
154
|
+
return false unless ref.is_a?(PDF::Reader::Reference)
|
|
155
|
+
return true if seen[ref.id]
|
|
156
|
+
|
|
157
|
+
seen[ref.id] = true
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def collect_fields(kids, prefix, seen)
|
|
162
|
+
kids.each do |ref|
|
|
163
|
+
next if visited?(ref, seen)
|
|
164
|
+
|
|
165
|
+
node = @doc.deref(ref)
|
|
166
|
+
next unless node.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
name = [prefix, decode_name(node[:T])].compact.join('.')
|
|
169
|
+
child_refs = @doc.deref(node[:Kids]) || []
|
|
170
|
+
child_pairs = child_refs.map { |kid| [kid, @doc.deref(kid)] }
|
|
171
|
+
.select { |_kid, dict| dict.is_a?(Hash) }
|
|
172
|
+
subfields, widgets = child_pairs.partition { |_kid, dict| dict.key?(:T) }
|
|
173
|
+
|
|
174
|
+
if subfields.any?
|
|
175
|
+
# Recurse only into the named subfields; a stray bare widget mixed
|
|
176
|
+
# into the same /Kids must not register itself under this name.
|
|
177
|
+
collect_fields(subfields.map(&:first), name, seen)
|
|
178
|
+
else
|
|
179
|
+
widget_dicts = widgets.map(&:last)
|
|
180
|
+
widget_dicts = [node] if widget_dicts.empty? && node[:Rect]
|
|
181
|
+
@fields[name] << { node: node, widgets: widget_dicts } unless name.empty?
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def decode_name(raw)
|
|
187
|
+
decode_text_string(@doc.deref(raw))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# PDF text strings are UTF-16BE (with BOM) or PDFDoc/Latin-ish; both are
|
|
191
|
+
# template-controlled, so decoding failures degrade instead of raising.
|
|
192
|
+
def decode_text_string(raw)
|
|
193
|
+
return raw unless raw.is_a?(String)
|
|
194
|
+
|
|
195
|
+
if raw.start_with?("\xFE\xFF".b)
|
|
196
|
+
raw.byteslice(2..).force_encoding('UTF-16BE')
|
|
197
|
+
.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
198
|
+
else
|
|
199
|
+
raw.dup.force_encoding('UTF-8').scrub
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def pdf_text_string(value)
|
|
204
|
+
return value.b if value.ascii_only?
|
|
205
|
+
|
|
206
|
+
"\xFE\xFF".b + value.encode('UTF-16BE').b
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def flatten_page(page)
|
|
210
|
+
annots = (@doc.deref(page[:Annots]) || []).map { |a| [a, @doc.deref(a)] }
|
|
211
|
+
widgets, others = annots.partition { |_ref, dict| dict.is_a?(Hash) && dict[:Subtype] == :Widget }
|
|
212
|
+
return if widgets.empty?
|
|
213
|
+
|
|
214
|
+
stamps = []
|
|
215
|
+
widgets.each do |_ref, widget|
|
|
216
|
+
stamp = stamp_operations(page, widget)
|
|
217
|
+
stamps << stamp if stamp
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
unless stamps.empty?
|
|
221
|
+
wrap = ->(bytes) { @doc.add(StreamObject.new({}, bytes.b)) }
|
|
222
|
+
derefed = @doc.deref(page[:Contents])
|
|
223
|
+
contents = (derefed.is_a?(Array) ? derefed : [page[:Contents]]).compact
|
|
224
|
+
contents = contents.map { |stream| @doc.ref_for(stream) }
|
|
225
|
+
page[:Contents] = [wrap.call("q\n"), *contents, wrap.call("\nQ\n#{stamps.join("\n")}\n")]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
remaining = others.map(&:first)
|
|
229
|
+
if remaining.empty?
|
|
230
|
+
page.delete(:Annots)
|
|
231
|
+
else
|
|
232
|
+
page[:Annots] = remaining
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Returns content-stream operations placing the widget's normal
|
|
237
|
+
# appearance onto the page, or nil when there is nothing to draw.
|
|
238
|
+
# Implements the appearance-box algorithm of PDF 32000 §12.5.5: the
|
|
239
|
+
# form's /Matrix is applied to its BBox, and the resulting extent is
|
|
240
|
+
# mapped onto the annotation rectangle.
|
|
241
|
+
def stamp_operations(page, widget)
|
|
242
|
+
return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
|
|
243
|
+
|
|
244
|
+
ap_ref = normal_appearance(widget)
|
|
245
|
+
xobject = @doc.deref(ap_ref)
|
|
246
|
+
dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
|
|
247
|
+
return nil unless dict.is_a?(Hash)
|
|
248
|
+
|
|
249
|
+
bbox = @doc.deref(dict[:BBox])
|
|
250
|
+
rect = @doc.deref(widget[:Rect])
|
|
251
|
+
return nil unless bbox.is_a?(Array) && rect.is_a?(Array)
|
|
252
|
+
|
|
253
|
+
# Appearance streams are form XObjects, but /Type and /Subtype are
|
|
254
|
+
# sometimes omitted; /Do requires them.
|
|
255
|
+
dict[:Type] ||= :XObject
|
|
256
|
+
dict[:Subtype] ||= :Form
|
|
257
|
+
|
|
258
|
+
llx, lly, urx, ury = normalize_box(rect)
|
|
259
|
+
bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
|
|
260
|
+
bw = bx1 - bx0
|
|
261
|
+
bh = by1 - by0
|
|
262
|
+
return nil if bw <= 0 || bh <= 0
|
|
263
|
+
|
|
264
|
+
sx = (urx - llx) / bw
|
|
265
|
+
sy = (ury - lly) / bh
|
|
266
|
+
name = register_xobject(page, ap_ref)
|
|
267
|
+
matrix = [sx, 0, 0, sy, llx - (bx0 * sx), lly - (by0 * sy)]
|
|
268
|
+
ops = matrix.map { |n| Serializer.format_number(n.to_f) }
|
|
269
|
+
"q #{ops.join(' ')} cm /#{name} Do Q"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Bounding box of the BBox corners after the form's /Matrix (identity
|
|
273
|
+
# when absent or malformed).
|
|
274
|
+
def transformed_bbox(bbox, matrix)
|
|
275
|
+
x0, y0, x1, y1 = normalize_box(bbox)
|
|
276
|
+
return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
|
|
277
|
+
matrix.all?(Numeric)
|
|
278
|
+
|
|
279
|
+
a, b, c, d, e, f = matrix.map(&:to_f)
|
|
280
|
+
xs = []
|
|
281
|
+
ys = []
|
|
282
|
+
[[x0, y0], [x1, y0], [x0, y1], [x1, y1]].each do |x, y|
|
|
283
|
+
xs << ((a * x) + (c * y) + e)
|
|
284
|
+
ys << ((b * x) + (d * y) + f)
|
|
285
|
+
end
|
|
286
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def normal_appearance(widget)
|
|
290
|
+
ap = @doc.deref(widget[:AP])
|
|
291
|
+
return nil unless ap.is_a?(Hash)
|
|
292
|
+
|
|
293
|
+
normal = ap[:N]
|
|
294
|
+
states = @doc.deref(normal)
|
|
295
|
+
if states.is_a?(Hash) && !states.is_a?(PDF::Reader::Stream)
|
|
296
|
+
# Pick the widget's current state; without /AS default to /Off
|
|
297
|
+
# (never an arbitrary "on" appearance for an unset checkbox).
|
|
298
|
+
state = @doc.deref(widget[:AS])
|
|
299
|
+
state = :Off unless state.is_a?(Symbol) && states.key?(state)
|
|
300
|
+
normal = states[state]
|
|
301
|
+
end
|
|
302
|
+
normal
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def normalize_box(box)
|
|
306
|
+
xs = [box[0].to_f, box[2].to_f].sort
|
|
307
|
+
ys = [box[1].to_f, box[3].to_f].sort
|
|
308
|
+
[xs[0], ys[0], xs[1], ys[1]]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def register_xobject(page, ap_ref)
|
|
312
|
+
resources = @doc.deref(page[:Resources]) || @doc.inherited_value(page, :Resources)
|
|
313
|
+
resources = @doc.deref(resources) || {}
|
|
314
|
+
resources = resources.dup
|
|
315
|
+
xobjects = (@doc.deref(resources[:XObject]) || {}).dup
|
|
316
|
+
|
|
317
|
+
@stamp_counter = (@stamp_counter || 0) + 1
|
|
318
|
+
name = :"AcrofillAP#{@stamp_counter}"
|
|
319
|
+
xobjects[name] = @doc.ref_for(ap_ref)
|
|
320
|
+
resources[:XObject] = xobjects
|
|
321
|
+
page[:Resources] = resources
|
|
322
|
+
name
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Glyph widths (1/1000 em) for standard-14 fonts, ASCII 32..126,
|
|
4
|
+
# extracted from Adobe AFM metrics. Index = char code - 32.
|
|
5
|
+
module Acrofill
|
|
6
|
+
module Metrics
|
|
7
|
+
WIDTHS = {
|
|
8
|
+
'Helvetica' => [278, 278, 355, 556, 556, 889, 667, 222, 333, 333, 389, 584, 278, 333, 278, 278, 556,
|
|
9
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 222, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584],
|
|
10
|
+
'Helvetica-Bold' => [278, 333, 474, 556, 556, 889, 722, 278, 333, 333, 389, 584, 278, 333, 278, 278,
|
|
11
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 278, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584],
|
|
12
|
+
'Courier' => [600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
|
|
13
|
+
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600],
|
|
14
|
+
'Times-Roman' => [250, 333, 408, 500, 500, 833, 778, 333, 333, 333, 500, 564, 250, 333, 250, 278, 500,
|
|
15
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
DEFAULT_WIDTH = 556
|
|
19
|
+
|
|
20
|
+
def self.string_width(str, base_font, size)
|
|
21
|
+
table = WIDTHS[base_font] || WIDTHS['Helvetica']
|
|
22
|
+
units = str.each_char.sum do |ch|
|
|
23
|
+
code = ch.ord
|
|
24
|
+
code.between?(32, 126) ? table[code - 32] : DEFAULT_WIDTH
|
|
25
|
+
end
|
|
26
|
+
units * size / 1000.0
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# Serializes Ruby values (as produced by pdf-reader) back into PDF syntax.
|
|
5
|
+
# References are renumbered through the map supplied by the Writer.
|
|
6
|
+
class Serializer
|
|
7
|
+
# PDF "regular characters": printable ASCII minus whitespace, the
|
|
8
|
+
# delimiters ( ) < > [ ] { } / % and the name-escape character #.
|
|
9
|
+
REGULAR_NAME_CHAR = %r{[!-~&&[^()<>\[\]{}/%#]]}
|
|
10
|
+
REGULAR_NAME = /\A#{REGULAR_NAME_CHAR.source}+\z/
|
|
11
|
+
MAX_DEPTH = 200
|
|
12
|
+
|
|
13
|
+
def self.format_number(num)
|
|
14
|
+
# PDF has no syntax for infinity/NaN, and #to_i on them raises;
|
|
15
|
+
# a non-finite number can only come from malformed input, so clamp.
|
|
16
|
+
return '0' unless num.finite?
|
|
17
|
+
return num.to_i.to_s if num == num.to_i
|
|
18
|
+
|
|
19
|
+
format('%.5f', num).sub(/\.?0+\z/, '')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(oid_map)
|
|
23
|
+
@oid_map = oid_map
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def serialize(obj, depth = 0)
|
|
27
|
+
# Bound recursion: a pathologically nested array/dict from malformed
|
|
28
|
+
# input would otherwise overflow the stack.
|
|
29
|
+
raise Error, 'object nesting too deep' if depth > MAX_DEPTH
|
|
30
|
+
|
|
31
|
+
case obj
|
|
32
|
+
when PDF::Reader::Reference
|
|
33
|
+
oid = @oid_map[obj.id]
|
|
34
|
+
oid ? "#{oid} 0 R" : 'null'
|
|
35
|
+
when Hash then serialize_dict(obj, depth)
|
|
36
|
+
when Array then "[#{obj.map { |el| serialize(el, depth + 1) }.join(' ')}]"
|
|
37
|
+
when Symbol then serialize_name(obj)
|
|
38
|
+
when String then serialize_string(obj)
|
|
39
|
+
when Integer then obj.to_s
|
|
40
|
+
when Float then self.class.format_number(obj)
|
|
41
|
+
when TrueClass then 'true'
|
|
42
|
+
when FalseClass then 'false'
|
|
43
|
+
when NilClass then 'null'
|
|
44
|
+
else
|
|
45
|
+
raise Error, "cannot serialize #{obj.class}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def serialize_dict(dict, depth = 0)
|
|
52
|
+
inner = dict.map { |k, v| "#{serialize_name(k)} #{serialize(v, depth + 1)}" }.join(' ')
|
|
53
|
+
"<< #{inner} >>"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def serialize_name(sym)
|
|
57
|
+
str = sym.to_s
|
|
58
|
+
return "/#{str}" if REGULAR_NAME.match?(str) # fast path: no escaping
|
|
59
|
+
|
|
60
|
+
encoded = str.bytes.map do |byte|
|
|
61
|
+
char = byte.chr
|
|
62
|
+
REGULAR_NAME_CHAR.match?(char) ? char : format('#%02X', byte)
|
|
63
|
+
end.join
|
|
64
|
+
"/#{encoded}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Hex strings sidestep every escaping concern for binary payloads
|
|
68
|
+
# (/ID entries, UTF-16BE values, Info metadata).
|
|
69
|
+
def serialize_string(str)
|
|
70
|
+
"<#{str.unpack1('H*').upcase}>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# A pre-parsed, reusable template. Parsing is the dominant cost of a
|
|
5
|
+
# fill (tokenizing and inflating the whole file); Template pays it once
|
|
6
|
+
# and restores a pristine object graph from a Marshal snapshot for each
|
|
7
|
+
# subsequent fill — roughly 50x faster than re-parsing per fill.
|
|
8
|
+
#
|
|
9
|
+
# template = Acrofill::Template.new('claim_form.pdf')
|
|
10
|
+
# template.fill_form('a.pdf', { 'Name' => 'Jane' }, flatten: true)
|
|
11
|
+
# template.fill_form('b.pdf', { 'Name' => 'John' }, flatten: true)
|
|
12
|
+
#
|
|
13
|
+
# Instances are cheap to keep around (the snapshot is a single string,
|
|
14
|
+
# comparable to the file size) and safe to share across threads: every
|
|
15
|
+
# fill works on its own restored copy.
|
|
16
|
+
class Template
|
|
17
|
+
def initialize(path)
|
|
18
|
+
doc = Document.new(path)
|
|
19
|
+
@snapshot = doc.snapshot
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fill_form(destination, data = {}, options = {})
|
|
23
|
+
Filler.new.apply(Document.restore(@snapshot), destination, data, options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Field metadata without touching disk again.
|
|
27
|
+
def fields
|
|
28
|
+
Form.new(Document.restore(@snapshot)).fields
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def field_names
|
|
32
|
+
fields.map(&:name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/md5'
|
|
4
|
+
|
|
5
|
+
module Acrofill
|
|
6
|
+
# Writes the document back out as a fresh PDF: only objects reachable from
|
|
7
|
+
# the trailer are emitted, renumbered contiguously, with a classic xref
|
|
8
|
+
# table. Existing streams are copied verbatim with their original filters.
|
|
9
|
+
class Writer
|
|
10
|
+
HEADER = "%PDF-1.6\n%\xE2\xE3\xCF\xD3\n".b
|
|
11
|
+
|
|
12
|
+
def initialize(document)
|
|
13
|
+
@doc = document
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
order = reachable_oids
|
|
18
|
+
oid_map = order.each_with_index.to_h { |oid, idx| [oid, idx + 1] }
|
|
19
|
+
serializer = Serializer.new(oid_map)
|
|
20
|
+
|
|
21
|
+
out = HEADER.dup
|
|
22
|
+
offsets = []
|
|
23
|
+
order.each do |oid|
|
|
24
|
+
offsets << out.bytesize
|
|
25
|
+
out << "#{oid_map[oid]} 0 obj\n"
|
|
26
|
+
out << serialize_object(@doc.objects[oid], serializer)
|
|
27
|
+
out << "\nendobj\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
xref_offset = out.bytesize
|
|
31
|
+
out << "xref\n0 #{order.size + 1}\n0000000000 65535 f \n"
|
|
32
|
+
offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
|
|
33
|
+
|
|
34
|
+
trailer = {
|
|
35
|
+
Size: order.size + 1,
|
|
36
|
+
Root: @doc.trailer[:Root],
|
|
37
|
+
Info: @doc.trailer[:Info],
|
|
38
|
+
ID: trailer_id(out)
|
|
39
|
+
}.compact
|
|
40
|
+
out << "trailer\n#{serializer.serialize(trailer)}\n"
|
|
41
|
+
out << "startxref\n#{xref_offset}\n%%EOF\n"
|
|
42
|
+
out
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Preserves the original /ID pair, or derives one from the serialized
|
|
48
|
+
# body so identical inputs always produce identical output bytes.
|
|
49
|
+
def trailer_id(body)
|
|
50
|
+
id = @doc.trailer[:ID]
|
|
51
|
+
return id if id.is_a?(Array) && id.size == 2 && id.all?(String)
|
|
52
|
+
|
|
53
|
+
digest = Digest::MD5.digest(body)
|
|
54
|
+
[digest, digest]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def serialize_object(obj, serializer)
|
|
58
|
+
dict, raw =
|
|
59
|
+
case obj
|
|
60
|
+
when PDF::Reader::Stream then [obj.hash, obj.data]
|
|
61
|
+
when StreamObject then [obj.dict, obj.raw]
|
|
62
|
+
else return serializer.serialize(obj)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
dict = dict.merge(Length: raw.bytesize)
|
|
66
|
+
"#{serializer.serialize(dict)}\nstream\n".b + raw.b + "\nendstream".b
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Breadth-first walk from the trailer; keeps original ordering stable
|
|
70
|
+
# and drops everything orphaned (old xref streams, removed form fields).
|
|
71
|
+
def reachable_oids
|
|
72
|
+
queue = [@doc.trailer[:Root], @doc.trailer[:Info]].compact
|
|
73
|
+
seen = {}
|
|
74
|
+
order = []
|
|
75
|
+
|
|
76
|
+
until queue.empty?
|
|
77
|
+
current = queue.shift
|
|
78
|
+
case current
|
|
79
|
+
when PDF::Reader::Reference
|
|
80
|
+
next if seen[current.id]
|
|
81
|
+
|
|
82
|
+
seen[current.id] = true
|
|
83
|
+
obj = @doc.objects[current.id]
|
|
84
|
+
next if obj.nil?
|
|
85
|
+
|
|
86
|
+
order << current.id
|
|
87
|
+
queue << obj
|
|
88
|
+
when Hash
|
|
89
|
+
queue.concat(current.values)
|
|
90
|
+
when Array
|
|
91
|
+
queue.concat(current)
|
|
92
|
+
when PDF::Reader::Stream
|
|
93
|
+
queue << current.hash
|
|
94
|
+
when StreamObject
|
|
95
|
+
queue << current.dict
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
order
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/acrofill.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'acrofill/version'
|
|
4
|
+
require_relative 'acrofill/document'
|
|
5
|
+
require_relative 'acrofill/metrics'
|
|
6
|
+
require_relative 'acrofill/serializer'
|
|
7
|
+
require_relative 'acrofill/writer'
|
|
8
|
+
require_relative 'acrofill/appearance'
|
|
9
|
+
require_relative 'acrofill/form'
|
|
10
|
+
require_relative 'acrofill/filler'
|
|
11
|
+
require_relative 'acrofill/template'
|
|
12
|
+
|
|
13
|
+
module Acrofill
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Mirrors the PdfForms.new(pdftk_path) constructor shape.
|
|
17
|
+
def self.new(*args)
|
|
18
|
+
Filler.new(*args)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.fill_form(template, destination, data = {}, options = {})
|
|
22
|
+
Filler.new.fill_form(template, destination, data, options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.fields(template)
|
|
26
|
+
Filler.new.fields(template)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.field_names(template)
|
|
30
|
+
Filler.new.field_names(template)
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: acrofill
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- stiig
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: pdf-reader
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.4'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.4'
|
|
27
|
+
description: Fills PDF form fields (text, checkbox, radio, choice) in-process and
|
|
28
|
+
optionally flattens the result into static page content. An MIT-licensed, no-dependencies-on-binaries
|
|
29
|
+
alternative to the pdftk fill_form pipeline.
|
|
30
|
+
email:
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/acrofill.rb
|
|
38
|
+
- lib/acrofill/appearance.rb
|
|
39
|
+
- lib/acrofill/document.rb
|
|
40
|
+
- lib/acrofill/filler.rb
|
|
41
|
+
- lib/acrofill/form.rb
|
|
42
|
+
- lib/acrofill/metrics.rb
|
|
43
|
+
- lib/acrofill/serializer.rb
|
|
44
|
+
- lib/acrofill/template.rb
|
|
45
|
+
- lib/acrofill/version.rb
|
|
46
|
+
- lib/acrofill/writer.rb
|
|
47
|
+
homepage: https://github.com/stiig/acrofill
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/stiig/acrofill
|
|
52
|
+
source_code_uri: https://github.com/stiig/acrofill
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
post_install_message:
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.0'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.1.6
|
|
70
|
+
signing_key:
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Pure-Ruby PDF form (AcroForm) filling and flattening
|
|
73
|
+
test_files: []
|