acrofill 0.1.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +33 -5
- data/lib/acrofill/appearance.rb +40 -51
- data/lib/acrofill/document.rb +24 -2
- data/lib/acrofill/flattener.rb +159 -0
- data/lib/acrofill/fonts.rb +144 -0
- data/lib/acrofill/form.rb +14 -140
- data/lib/acrofill/metrics.rb +226 -17
- data/lib/acrofill/version.rb +1 -1
- data/lib/acrofill.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7b9246e478c6ab2f8206ba44e766a2b4e9e9a946a48245d171d7e212bac54d2
|
|
4
|
+
data.tar.gz: e6f70298a5008e1419089328788cb1d66908cd3e2edfa2e3bd724e0a220ff176
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f858c6d8e34cf948d8ef554d31d9e0a7187408f256fadff63c15bd4af3ad6ad6e61329770e9db506482f179635b9305393d432a9176742893aa45643810f22c
|
|
7
|
+
data.tar.gz: 75b8174ebb610ec9be61a7b214a69a47071d5aa797bb13378917eb3d41b4279c9ee81e1348641703dd29e5436ca1dcda30148b3e50c70759b2d47fad8c137cb1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-07-27
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Single-line baselines now match pdftk when the text is taller than the
|
|
15
|
+
field, which real forms hit routinely (a 12pt `/DA` in a 10.8pt-high box
|
|
16
|
+
is common). Centering is bounded on both sides: the ascender is kept
|
|
17
|
+
inside the box and the baseline never drops below the box floor. This was
|
|
18
|
+
the last geometry difference on a 551-field sample of real templates.
|
|
19
|
+
- Text is now measured with the metrics of the font it is actually drawn
|
|
20
|
+
with. A template that embeds its own face declares `/Widths` and a
|
|
21
|
+
`/FontDescriptor`, and those were ignored in favour of standard-14
|
|
22
|
+
tables — which put centered and right-aligned values as much as tens of
|
|
23
|
+
points away from where pdftk puts them (43pt on a 300pt-wide field in
|
|
24
|
+
one measured case). `/Widths` now drives glyph widths, `/FontDescriptor`
|
|
25
|
+
`/Ascent` the baseline and its `/FontBBox` the multiline row spacing,
|
|
26
|
+
falling back to the standard-14 tables only when the dictionary is
|
|
27
|
+
silent. This is the layout most real-world forms hit, since almost all
|
|
28
|
+
of them embed a subset face.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Vertical geometry is now font-aware and matches pdftk-java 3.3.3 exactly.
|
|
33
|
+
Baselines are placed from the font's own AFM ascender instead of a fixed
|
|
34
|
+
Helvetica value (Times sat 0.18pt low, Courier 0.45pt), and multiline rows
|
|
35
|
+
are spaced by the font's `FontBBox` extent with pdftk's 1pt top offset
|
|
36
|
+
instead of a flat `1.15 * size` leading.
|
|
37
|
+
- `Acrofill::Metrics` exposes `.font_for`, returning widths plus ascender,
|
|
38
|
+
descender and `FontBBox` for one of the twelve standard-14 text cuts.
|
|
39
|
+
Vertical metrics are stored per cut, since Courier-Bold and Times-Italic
|
|
40
|
+
differ there even where their widths do not.
|
|
41
|
+
- Font resolution moved out of `Appearance` into `Acrofill::Fonts`, which
|
|
42
|
+
owns the `/DR /Font` dictionary: metrics for a `/DA` resource name and
|
|
43
|
+
the reference a generated appearance points at.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `benchmark/geometry_diff.rb` compares acrofill's appearance streams with
|
|
48
|
+
pdftk's field by field on your own templates.
|
|
49
|
+
- `spec/pdftk_parity_spec.rb` pins alignment, baseline and multiline row
|
|
50
|
+
geometry — for standard-14 faces, template-supplied metrics, and text
|
|
51
|
+
taller than its field — to numbers measured from pdftk's own output.
|
|
52
|
+
|
|
53
|
+
### Known differences from pdftk
|
|
54
|
+
|
|
55
|
+
- Auto-sized fields (`0 Tf`): pdftk picks a font-dependent size that fills
|
|
56
|
+
the box (16.33pt in a 20pt box for Helvetica, and 20.73pt — taller than
|
|
57
|
+
the box — for Courier), with a hard 4pt floor. Acrofill keeps its own
|
|
58
|
+
`min(height * 0.66, 12pt)` and is not going to reproduce that.
|
|
59
|
+
- Values too wide for the field: acrofill shrinks the font to fit, pdftk
|
|
60
|
+
keeps the size and clips.
|
|
61
|
+
- Non-ASCII values: pdftk writes UTF-8 bytes into a `/WinAnsiEncoding`
|
|
62
|
+
font, which renders as mojibake; acrofill writes Windows-1252, so the
|
|
63
|
+
text is correct and the measured width differs accordingly.
|
|
64
|
+
|
|
65
|
+
## [0.2.0] - 2026-07-27
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- Pages whose `/Type` entry is missing are no longer skipped: flattening
|
|
70
|
+
such a document silently produced an unflattened file.
|
|
71
|
+
- A widget whose appearance cannot be regenerated (degenerate `/Rect`)
|
|
72
|
+
now has its `/AP` dropped instead of keeping the appearance of the
|
|
73
|
+
*previous* value while `/V` already holds the new one.
|
|
74
|
+
- A page `/Resources` or `/Resources /XObject` that is not a dictionary
|
|
75
|
+
no longer raises `TypeError` while flattening, and a `/Root` that is
|
|
76
|
+
missing or not a dictionary raises `Acrofill::Error` instead of
|
|
77
|
+
`NoMethodError`.
|
|
78
|
+
- Filling a field whose fully-qualified name is shared by several field
|
|
79
|
+
dictionaries no longer depends on the return value of the first fill.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
- Text metrics now cover all standard-14 text cuts (Times bold/italic,
|
|
84
|
+
the Courier family, the oblique Helvetica cuts) over the full
|
|
85
|
+
WinAnsiEncoding range instead of ASCII-only Helvetica/Courier/
|
|
86
|
+
Times-Roman, so accented characters and bold or serif faces are
|
|
87
|
+
measured rather than approximated. Widths are WinAnsi-correct: `'`
|
|
88
|
+
measured 222 (StandardEncoding `quoteright`) where appearances
|
|
89
|
+
actually emit `quotesingle`.
|
|
90
|
+
- Unknown `/BaseFont` names (`ArialMT`, `TimesNewRomanPS-BoldMT`, subset
|
|
91
|
+
faces) are classified by family and weight instead of all falling back
|
|
92
|
+
to Helvetica.
|
|
93
|
+
- One fallback Helvetica font object is now shared by every generated
|
|
94
|
+
appearance; a 50-field form previously wrote 50 identical font
|
|
95
|
+
dictionaries.
|
|
96
|
+
- Flattening moved out of `Form` into its own `Acrofill::Flattener`.
|
|
97
|
+
|
|
10
98
|
## [0.1.2] - 2026-07-24
|
|
11
99
|
|
|
12
100
|
### Added
|
|
@@ -54,7 +142,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
54
142
|
- `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
|
|
55
143
|
`field_names`).
|
|
56
144
|
|
|
57
|
-
[Unreleased]: https://github.com/stiig/acrofill/compare/v0.
|
|
145
|
+
[Unreleased]: https://github.com/stiig/acrofill/compare/v0.3.0...HEAD
|
|
146
|
+
[0.3.0]: https://github.com/stiig/acrofill/compare/v0.2.0...v0.3.0
|
|
147
|
+
[0.2.0]: https://github.com/stiig/acrofill/compare/v0.1.2...v0.2.0
|
|
58
148
|
[0.1.2]: https://github.com/stiig/acrofill/compare/v0.1.1...v0.1.2
|
|
59
149
|
[0.1.1]: https://github.com/stiig/acrofill/compare/v0.1.0...v0.1.1
|
|
60
150
|
[0.1.0]: https://github.com/stiig/acrofill/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -75,6 +75,28 @@ filler.fill_form(tpl, out, data, flatten: true)
|
|
|
75
75
|
|
|
76
76
|
Unknown field names are silently ignored, matching pdftk.
|
|
77
77
|
|
|
78
|
+
### Geometry parity
|
|
79
|
+
|
|
80
|
+
Filled text lands where pdftk puts it: across four real-world
|
|
81
|
+
government claim forms — 551 filled widgets — every appearance agrees with
|
|
82
|
+
pdftk-java 3.3.3 to the two decimals it prints, except where acrofill
|
|
83
|
+
deliberately differs (below). `spec/pdftk_parity_spec.rb` pins that
|
|
84
|
+
placement against numbers read out of pdftk's own appearance streams —
|
|
85
|
+
alignment, baselines for Helvetica/Times/Courier across box heights, text
|
|
86
|
+
taller than its field, multiline row spacing, and the widths, ascent and
|
|
87
|
+
`FontBBox` a template's own font dictionary supplies. Check it against
|
|
88
|
+
your own templates with:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ruby benchmark/geometry_diff.rb path/to/form.pdf
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Three differences are deliberate: acrofill shrinks an overlong value to fit
|
|
95
|
+
where pdftk clips it, it writes Windows-1252 for non-ASCII values where
|
|
96
|
+
pdftk emits UTF-8 bytes into a WinAnsi font (which renders as mojibake), and
|
|
97
|
+
it caps auto-sized (`0 Tf`) text at 12pt where pdftk scales it to fill the
|
|
98
|
+
box. See the changelog for the measured numbers.
|
|
99
|
+
|
|
78
100
|
## Performance
|
|
79
101
|
|
|
80
102
|
Because Acrofill runs in-process, it avoids the JVM (or C++ process)
|
|
@@ -147,9 +169,10 @@ corruption or code execution. Specifically it defends against:
|
|
|
147
169
|
name and only well-formed colour operators (`g`/`rg`/`k`) are copied
|
|
148
170
|
into generated appearances. Field **values** are always written as
|
|
149
171
|
escaped literals / hex strings, never as operators.
|
|
150
|
-
- **Malformed scalars** — non-finite reals, deeply
|
|
151
|
-
|
|
152
|
-
|
|
172
|
+
- **Malformed scalars and mistyped structure** — non-finite reals, deeply
|
|
173
|
+
nested arrays, a `/Resources` that is not a dictionary, a `/Root` that
|
|
174
|
+
points nowhere: each is clamped, replaced or rejected rather than
|
|
175
|
+
raising a `TypeError`/`NoMethodError` out of the middle of a fill.
|
|
153
176
|
- **Encrypted / unparseable input** — rejected up front; every failure
|
|
154
177
|
at the parse boundary surfaces as `Acrofill::Error` (including lazy
|
|
155
178
|
per-object parse errors and parser stack overflow on pathologically
|
|
@@ -162,8 +185,13 @@ Supported:
|
|
|
162
185
|
- Text fields (`/Tx`) — hierarchical names (`parent.kid`), inherited
|
|
163
186
|
`/DA`, alignment via `/Q` (left/center/right), auto font size (`0 Tf`),
|
|
164
187
|
shrink-to-fit for overflowing values, multiline fields (`/Ff` bit 13)
|
|
165
|
-
with word wrapping,
|
|
166
|
-
|
|
188
|
+
with word wrapping, and text measured with the metrics of the font it is
|
|
189
|
+
drawn with: a template's own `/Widths` and `/FontDescriptor` when the
|
|
190
|
+
face carries them (as embedded subsets do), otherwise WinAnsi tables for
|
|
191
|
+
all standard-14 cuts — widths, ascender and `FontBBox`, so baselines and
|
|
192
|
+
row spacing follow the actual face. A `/BaseFont` naming no standard cut
|
|
193
|
+
(`ArialMT`, `TimesNewRomanPS-BoldMT`) is classified by family and weight
|
|
194
|
+
rather than all measured as Helvetica.
|
|
167
195
|
- Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
|
|
168
196
|
using the template's own appearance states.
|
|
169
197
|
- Choice fields (`/Ch`) — value set and rendered like text.
|
data/lib/acrofill/appearance.rb
CHANGED
|
@@ -5,14 +5,15 @@ module Acrofill
|
|
|
5
5
|
# text-field widget, honouring the field's /DA string and /Q alignment.
|
|
6
6
|
class Appearance
|
|
7
7
|
PADDING = 2.0
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# pdftk offsets the first multiline row by 1pt from the box top.
|
|
9
|
+
TOP_OFFSET = 1.0
|
|
10
10
|
# Colour-setting operators allowed in a /DA string, and their operand counts.
|
|
11
11
|
COLOR_OP_ARITY = { 'g' => 1, 'rg' => 3, 'k' => 4 }.freeze
|
|
12
12
|
|
|
13
13
|
def initialize(doc, acroform)
|
|
14
14
|
@doc = doc
|
|
15
15
|
@acroform = acroform
|
|
16
|
+
@fonts = Fonts.new(doc, acroform)
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Returns a Reference to the new appearance XObject, or nil when the
|
|
@@ -26,22 +27,19 @@ module Acrofill
|
|
|
26
27
|
return nil if width <= 0 || height <= 0
|
|
27
28
|
|
|
28
29
|
font_name, size, color_ops = parse_da(field_node)
|
|
29
|
-
|
|
30
|
+
font = @fonts.metrics(font_name)
|
|
30
31
|
align = alignment(field_node)
|
|
31
32
|
|
|
32
33
|
body =
|
|
33
34
|
if multiline
|
|
34
35
|
size = 12.0 if size <= 0
|
|
35
36
|
size = size.clamp(2.0, 144.0)
|
|
36
|
-
multiline_body(value,
|
|
37
|
+
multiline_body(value, font, size, width, height, align)
|
|
37
38
|
else
|
|
38
39
|
text = printable_text(value)
|
|
39
40
|
size = [height * 0.66, 12.0].min if size.zero?
|
|
40
|
-
size = shrink_to_fit(text,
|
|
41
|
-
#
|
|
42
|
-
# placement exactly: ty = (h - ascent*size) / 2.
|
|
43
|
-
ty = [(height - (size * ASCENT)) / 2.0, size * DESCENT].max
|
|
44
|
-
"#{fmt(line_x(text, base_font, size, width, align))} #{fmt(ty)} Td\n" \
|
|
41
|
+
size = shrink_to_fit(text, font, size, width)
|
|
42
|
+
"#{fmt(line_x(text, font, size, width, align))} #{fmt(baseline(height, font, size))} Td\n" \
|
|
45
43
|
"(#{escape_literal(text)}) Tj\n"
|
|
46
44
|
end
|
|
47
45
|
|
|
@@ -56,7 +54,7 @@ module Acrofill
|
|
|
56
54
|
Subtype: :Form,
|
|
57
55
|
FormType: 1,
|
|
58
56
|
BBox: [0, 0, width, height],
|
|
59
|
-
Resources: { Font: { font_name.to_sym =>
|
|
57
|
+
Resources: { Font: { font_name.to_sym => @fonts.reference(font_name) } }
|
|
60
58
|
}
|
|
61
59
|
@doc.add(StreamObject.new(dict, content.b))
|
|
62
60
|
end
|
|
@@ -67,14 +65,27 @@ module Acrofill
|
|
|
67
65
|
Serializer.format_number(num.to_f)
|
|
68
66
|
end
|
|
69
67
|
|
|
68
|
+
# Where pdftk puts a single line's baseline: the ascent box centered in
|
|
69
|
+
# the field, but never so low that the descender leaves the box, never so
|
|
70
|
+
# high that the ascender does, and never below the box floor. The last
|
|
71
|
+
# two only bind when the text is taller than the field it sits in, which
|
|
72
|
+
# real forms do hit — a 12pt /DA in a 10.8pt-high field is common.
|
|
73
|
+
def baseline(height, font, size)
|
|
74
|
+
ascent = font.ascent(size)
|
|
75
|
+
highest = height - ascent # baseline putting the ascender at the box top
|
|
76
|
+
return 0.0 if highest <= 0 # the glyphs are taller than the box
|
|
77
|
+
|
|
78
|
+
[highest / 2.0, font.descent(size)].max.clamp(0.0, highest)
|
|
79
|
+
end
|
|
80
|
+
|
|
70
81
|
# /Q (0 left, 1 center, 2 right), inheritable and possibly indirect.
|
|
71
82
|
def alignment(field_node)
|
|
72
83
|
align = @doc.deref(@doc.inherited_value(field_node, :Q) || @acroform[:Q])
|
|
73
84
|
align.is_a?(Integer) ? align : 0
|
|
74
85
|
end
|
|
75
86
|
|
|
76
|
-
def line_x(text,
|
|
77
|
-
text_width = Metrics.string_width(text,
|
|
87
|
+
def line_x(text, font, size, width, align)
|
|
88
|
+
text_width = Metrics.string_width(text, font.widths, size)
|
|
78
89
|
case align
|
|
79
90
|
when 1 then [(width - text_width) / 2.0, PADDING].max
|
|
80
91
|
when 2 then [width - PADDING - text_width, PADDING].max
|
|
@@ -84,18 +95,20 @@ module Acrofill
|
|
|
84
95
|
|
|
85
96
|
# Greedy word wrap, top-down, honouring explicit line breaks. Lines
|
|
86
97
|
# that would fall below the box are clipped by the BBox.
|
|
87
|
-
def multiline_body(value,
|
|
98
|
+
def multiline_body(value, font, size, width, height, align)
|
|
88
99
|
max_width = width - (2 * PADDING)
|
|
89
100
|
lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
|
|
90
|
-
wrap_line(printable_text(paragraph),
|
|
101
|
+
wrap_line(printable_text(paragraph), font, size, max_width)
|
|
91
102
|
end
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
# pdftk spaces rows by the font's FontBBox extent and drops the first
|
|
105
|
+
# baseline by that extent from the top of the box.
|
|
106
|
+
leading = font.line_height(size)
|
|
107
|
+
first_y = height - font.top(size) + TOP_OFFSET
|
|
95
108
|
body = "#{fmt(leading)} TL\n"
|
|
96
109
|
previous_x = 0.0
|
|
97
110
|
lines.each_with_index do |line, index|
|
|
98
|
-
x = line_x(line,
|
|
111
|
+
x = line_x(line, font, size, width, align)
|
|
99
112
|
body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
|
|
100
113
|
body << "(#{escape_literal(line)}) Tj\nT*\n"
|
|
101
114
|
previous_x = x
|
|
@@ -105,21 +118,21 @@ module Acrofill
|
|
|
105
118
|
|
|
106
119
|
# Greedy wrap. Line and space widths are accumulated incrementally so
|
|
107
120
|
# the cost is O(total characters), not O(words * line-length).
|
|
108
|
-
def wrap_line(text,
|
|
109
|
-
space = Metrics.string_width(' ',
|
|
121
|
+
def wrap_line(text, font, size, max_width)
|
|
122
|
+
space = Metrics.string_width(' ', font.widths, size)
|
|
110
123
|
lines = ['']
|
|
111
|
-
|
|
124
|
+
so_far = [0.0]
|
|
112
125
|
text.split.each do |word|
|
|
113
|
-
word_width = Metrics.string_width(word,
|
|
126
|
+
word_width = Metrics.string_width(word, font.widths, size)
|
|
114
127
|
if lines.last.empty?
|
|
115
128
|
lines[-1] = word
|
|
116
|
-
|
|
117
|
-
elsif
|
|
129
|
+
so_far[-1] = word_width
|
|
130
|
+
elsif so_far.last + space + word_width <= max_width
|
|
118
131
|
lines[-1] = "#{lines.last} #{word}"
|
|
119
|
-
|
|
132
|
+
so_far[-1] += space + word_width
|
|
120
133
|
else
|
|
121
134
|
lines << word
|
|
122
|
-
|
|
135
|
+
so_far << word_width
|
|
123
136
|
end
|
|
124
137
|
end
|
|
125
138
|
lines
|
|
@@ -182,37 +195,13 @@ module Acrofill
|
|
|
182
195
|
|
|
183
196
|
# Fixed sizes that overflow the box are scaled down so the whole value
|
|
184
197
|
# stays visible (Acrobat-style best-fit; pdftk would clip instead).
|
|
185
|
-
def shrink_to_fit(text,
|
|
198
|
+
def shrink_to_fit(text, font, size, width)
|
|
186
199
|
max_width = width - (2 * PADDING)
|
|
187
|
-
text_width = Metrics.string_width(text,
|
|
200
|
+
text_width = Metrics.string_width(text, font.widths, size)
|
|
188
201
|
size *= max_width / text_width if text_width > max_width && text_width.positive?
|
|
189
202
|
size.clamp(2.0, 144.0)
|
|
190
203
|
end
|
|
191
204
|
|
|
192
|
-
# The font resource dictionary from /AcroForm /DR /Font, or {} when the
|
|
193
|
-
# template supplies a malformed (non-dictionary) /DR or /Font.
|
|
194
|
-
def dr_fonts
|
|
195
|
-
dr = @doc.deref(@acroform[:DR])
|
|
196
|
-
return {} unless dr.is_a?(Hash)
|
|
197
|
-
|
|
198
|
-
fonts = @doc.deref(dr[:Font])
|
|
199
|
-
fonts.is_a?(Hash) ? fonts : {}
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def base_font_for(resource_name)
|
|
203
|
-
font = @doc.deref(dr_fonts[resource_name.to_sym])
|
|
204
|
-
base = font.is_a?(Hash) ? font[:BaseFont].to_s : ''
|
|
205
|
-
base.sub(/\A[A-Z]{6}\+/, '')
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def font_ref(resource_name)
|
|
209
|
-
entry = dr_fonts[resource_name.to_sym]
|
|
210
|
-
return @doc.ref_for(entry) if entry
|
|
211
|
-
|
|
212
|
-
# Font not present in /DR: register a plain Helvetica.
|
|
213
|
-
@doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
205
|
def printable_text(value)
|
|
217
206
|
value.to_s.gsub(/[[:space:]]+/, ' ').strip
|
|
218
207
|
.encode('Windows-1252', invalid: :replace, undef: :replace, replace: '?')
|
data/lib/acrofill/document.rb
CHANGED
|
@@ -11,6 +11,8 @@ module Acrofill
|
|
|
11
11
|
# materialized once via pdf-reader (which transparently handles xref
|
|
12
12
|
# streams and object streams), then mutated in place before writing.
|
|
13
13
|
class Document
|
|
14
|
+
PAGE_NODE_TYPES = %i[Pages Page].freeze
|
|
15
|
+
|
|
14
16
|
attr_reader :objects, :trailer
|
|
15
17
|
|
|
16
18
|
def initialize(path)
|
|
@@ -96,8 +98,14 @@ module Acrofill
|
|
|
96
98
|
obj.is_a?(PDF::Reader::Reference) ? obj : add(obj)
|
|
97
99
|
end
|
|
98
100
|
|
|
101
|
+
# The document catalog. The trailer may point at a missing or mistyped
|
|
102
|
+
# object, so this is a parse-boundary check too: callers get an
|
|
103
|
+
# Acrofill::Error rather than a NoMethodError on nil.
|
|
99
104
|
def root
|
|
100
|
-
deref(@trailer[:Root])
|
|
105
|
+
node = deref(@trailer[:Root])
|
|
106
|
+
raise Error, 'PDF has no document catalog' unless node.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
node
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
# Depth-first, document-order walk over the /Pages tree. Iterative with
|
|
@@ -118,7 +126,7 @@ module Acrofill
|
|
|
118
126
|
seen[node.id] = true
|
|
119
127
|
end
|
|
120
128
|
|
|
121
|
-
case dict
|
|
129
|
+
case node_type(dict)
|
|
122
130
|
when :Pages
|
|
123
131
|
kids = deref(dict[:Kids])
|
|
124
132
|
stack.concat(kids.reverse) if kids.is_a?(Array)
|
|
@@ -140,5 +148,19 @@ module Acrofill
|
|
|
140
148
|
end
|
|
141
149
|
nil
|
|
142
150
|
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# /Type is required on page-tree nodes but plenty of real generators omit
|
|
155
|
+
# it, and a page skipped here is a page silently not flattened. Infer it
|
|
156
|
+
# from the shape instead: a node with /Kids is an internal node, anything
|
|
157
|
+
# else reached from the page tree is a leaf.
|
|
158
|
+
def node_type(dict)
|
|
159
|
+
type = deref(dict[:Type])
|
|
160
|
+
return type if PAGE_NODE_TYPES.include?(type)
|
|
161
|
+
return nil if type.is_a?(Symbol) # something else entirely, e.g. /Font
|
|
162
|
+
|
|
163
|
+
dict.key?(:Kids) ? :Pages : :Page
|
|
164
|
+
end
|
|
143
165
|
end
|
|
144
166
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# Burns widget appearances into page content and removes the interactive
|
|
5
|
+
# layer, like pdftk's `output ... flatten`. Every geometry value it reads
|
|
6
|
+
# comes from the template, so each one is validated before use.
|
|
7
|
+
class Flattener
|
|
8
|
+
HIDDEN_FLAG = 2
|
|
9
|
+
|
|
10
|
+
def initialize(doc)
|
|
11
|
+
@doc = doc
|
|
12
|
+
@stamp_counter = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def flatten!
|
|
16
|
+
@doc.each_page { |page| flatten_page(page) }
|
|
17
|
+
@doc.root.delete(:AcroForm)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Annotation /F flags. Must be dereferenced before to_i: on a
|
|
23
|
+
# PDF::Reader::Reference, to_i returns the object *number*.
|
|
24
|
+
def annotation_flags(widget)
|
|
25
|
+
flags = @doc.deref(widget[:F])
|
|
26
|
+
flags.is_a?(Integer) ? flags : 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def flatten_page(page)
|
|
30
|
+
annot_refs = @doc.deref(page[:Annots])
|
|
31
|
+
annot_refs = [] unless annot_refs.is_a?(Array)
|
|
32
|
+
annots = annot_refs.map { |a| [a, @doc.deref(a)] }
|
|
33
|
+
widgets, others = annots.partition { |_ref, dict| dict.is_a?(Hash) && dict[:Subtype] == :Widget }
|
|
34
|
+
return if widgets.empty?
|
|
35
|
+
|
|
36
|
+
stamps = []
|
|
37
|
+
widgets.each do |_ref, widget|
|
|
38
|
+
stamp = stamp_operations(page, widget)
|
|
39
|
+
stamps << stamp if stamp
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unless stamps.empty?
|
|
43
|
+
wrap = ->(bytes) { @doc.add(StreamObject.new({}, bytes.b)) }
|
|
44
|
+
derefed = @doc.deref(page[:Contents])
|
|
45
|
+
contents = (derefed.is_a?(Array) ? derefed : [page[:Contents]]).compact
|
|
46
|
+
contents = contents.map { |stream| @doc.ref_for(stream) }
|
|
47
|
+
page[:Contents] = [wrap.call("q\n"), *contents, wrap.call("\nQ\n#{stamps.join("\n")}\n")]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
remaining = others.map(&:first)
|
|
51
|
+
if remaining.empty?
|
|
52
|
+
page.delete(:Annots)
|
|
53
|
+
else
|
|
54
|
+
page[:Annots] = remaining
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns content-stream operations placing the widget's normal
|
|
59
|
+
# appearance onto the page, or nil when there is nothing to draw.
|
|
60
|
+
# Implements the appearance-box algorithm of PDF 32000 §12.5.5: the
|
|
61
|
+
# form's /Matrix is applied to its BBox, and the resulting extent is
|
|
62
|
+
# mapped onto the annotation rectangle.
|
|
63
|
+
def stamp_operations(page, widget)
|
|
64
|
+
return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
|
|
65
|
+
|
|
66
|
+
ap_ref = normal_appearance(widget)
|
|
67
|
+
xobject = @doc.deref(ap_ref)
|
|
68
|
+
dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
|
|
69
|
+
return nil unless dict.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
bbox = normalize_box(@doc.deref(dict[:BBox]))
|
|
72
|
+
rect = normalize_box(@doc.deref(widget[:Rect]))
|
|
73
|
+
return nil unless bbox && rect
|
|
74
|
+
|
|
75
|
+
# Appearance streams are form XObjects, but /Type and /Subtype are
|
|
76
|
+
# sometimes omitted; /Do requires them.
|
|
77
|
+
dict[:Type] ||= :XObject
|
|
78
|
+
dict[:Subtype] ||= :Form
|
|
79
|
+
|
|
80
|
+
llx, lly, urx, ury = rect
|
|
81
|
+
bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
|
|
82
|
+
bw = bx1 - bx0
|
|
83
|
+
bh = by1 - by0
|
|
84
|
+
return nil if bw <= 0 || bh <= 0
|
|
85
|
+
|
|
86
|
+
sx = (urx - llx) / bw
|
|
87
|
+
sy = (ury - lly) / bh
|
|
88
|
+
name = register_xobject(page, ap_ref)
|
|
89
|
+
matrix = [sx, 0, 0, sy, llx - (bx0 * sx), lly - (by0 * sy)]
|
|
90
|
+
ops = matrix.map { |n| Serializer.format_number(n.to_f) }
|
|
91
|
+
"q #{ops.join(' ')} cm /#{name} Do Q"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Bounding box of the (already normalized) BBox corners after the
|
|
95
|
+
# form's /Matrix (identity when absent or malformed).
|
|
96
|
+
def transformed_bbox(bbox, matrix)
|
|
97
|
+
x0, y0, x1, y1 = bbox
|
|
98
|
+
matrix = matrix.map { |m| @doc.deref(m) } if matrix.is_a?(Array)
|
|
99
|
+
return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
|
|
100
|
+
matrix.all?(Numeric)
|
|
101
|
+
|
|
102
|
+
a, b, c, d, e, f = matrix.map(&:to_f)
|
|
103
|
+
xs = []
|
|
104
|
+
ys = []
|
|
105
|
+
[[x0, y0], [x1, y0], [x0, y1], [x1, y1]].each do |x, y|
|
|
106
|
+
xs << ((a * x) + (c * y) + e)
|
|
107
|
+
ys << ((b * x) + (d * y) + f)
|
|
108
|
+
end
|
|
109
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normal_appearance(widget)
|
|
113
|
+
ap = @doc.deref(widget[:AP])
|
|
114
|
+
return nil unless ap.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
normal = ap[:N]
|
|
117
|
+
states = @doc.deref(normal)
|
|
118
|
+
if states.is_a?(Hash) && !states.is_a?(PDF::Reader::Stream)
|
|
119
|
+
# Pick the widget's current state; without /AS default to /Off
|
|
120
|
+
# (never an arbitrary "on" appearance for an unset checkbox).
|
|
121
|
+
state = @doc.deref(widget[:AS])
|
|
122
|
+
state = :Off unless state.is_a?(Symbol) && states.key?(state)
|
|
123
|
+
normal = states[state]
|
|
124
|
+
end
|
|
125
|
+
normal
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Derefs each element (array entries may legally be indirect objects)
|
|
129
|
+
# and returns [llx, lly, urx, ury], or nil when the box is not four
|
|
130
|
+
# numbers.
|
|
131
|
+
def normalize_box(box)
|
|
132
|
+
return nil unless box.is_a?(Array) && box.size == 4
|
|
133
|
+
|
|
134
|
+
nums = box.map { |n| @doc.deref(n) }
|
|
135
|
+
return nil unless nums.all?(Numeric)
|
|
136
|
+
|
|
137
|
+
xs = [nums[0].to_f, nums[2].to_f].sort
|
|
138
|
+
ys = [nums[1].to_f, nums[3].to_f].sort
|
|
139
|
+
[xs[0], ys[0], xs[1], ys[1]]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A page's /Resources (and its /XObject subdictionary) are template
|
|
143
|
+
# data: anything that is not a dictionary is replaced rather than
|
|
144
|
+
# indexed, which would raise TypeError on an Array or a stream.
|
|
145
|
+
def register_xobject(page, ap_ref)
|
|
146
|
+
resources = @doc.deref(page[:Resources]) || @doc.deref(@doc.inherited_value(page, :Resources))
|
|
147
|
+
resources = resources.is_a?(Hash) ? resources.dup : {}
|
|
148
|
+
xobjects = @doc.deref(resources[:XObject])
|
|
149
|
+
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
|
|
155
|
+
page[:Resources] = resources
|
|
156
|
+
name
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acrofill
|
|
4
|
+
# The form's /DR /Font dictionary: the metrics a widget's /DA font resource
|
|
5
|
+
# actually implies, and the reference to put in a generated appearance's
|
|
6
|
+
# /Resources.
|
|
7
|
+
#
|
|
8
|
+
# Real templates embed their own faces, and those font dictionaries carry
|
|
9
|
+
# their own /Widths and /FontDescriptor. The appearance stream is drawn
|
|
10
|
+
# with that very font, so it has to be measured with it too — laying an
|
|
11
|
+
# embedded face out against standard-14 tables misplaces every centered or
|
|
12
|
+
# right-aligned value. pdftk reads the dictionary; these are the same rules,
|
|
13
|
+
# measured against pdftk-java 3.3.3 (see benchmark/geometry_diff.rb):
|
|
14
|
+
#
|
|
15
|
+
# widths /Widths + /FirstChar, else the standard-14 table
|
|
16
|
+
# ascender /FontDescriptor /Ascent, else standard-14 AFM, else 800
|
|
17
|
+
# FontBBox /FontDescriptor /FontBBox, else standard-14 AFM, else 900/-200
|
|
18
|
+
#
|
|
19
|
+
# The standard-14 fallbacks apply only to a BaseFont that literally names
|
|
20
|
+
# one of the fourteen; a face merely *resembling* one (ArialMT and friends)
|
|
21
|
+
# gets pdftk's generic defaults vertically, while its widths still fall back
|
|
22
|
+
# to the closest standard table, there being nothing better to measure with.
|
|
23
|
+
class Fonts
|
|
24
|
+
DEFAULT_ASCENDER = 800
|
|
25
|
+
DEFAULT_DESCENDER = -200
|
|
26
|
+
DEFAULT_BBOX_TOP = 900
|
|
27
|
+
DEFAULT_BBOX_BOTTOM = -200
|
|
28
|
+
# A code outside /FirstChar../LastChar draws as zero-width: pdftk does
|
|
29
|
+
# not consult /MissingWidth (verified — setting it changes nothing).
|
|
30
|
+
OUT_OF_RANGE_WIDTH = 0
|
|
31
|
+
SUBSET_PREFIX = /\A[A-Z]{6}\+/
|
|
32
|
+
|
|
33
|
+
def initialize(doc, acroform)
|
|
34
|
+
@doc = doc
|
|
35
|
+
@acroform = acroform
|
|
36
|
+
@metrics = {}
|
|
37
|
+
@references = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Metrics::Font for the font a /DA string names.
|
|
41
|
+
def metrics(resource_name)
|
|
42
|
+
key = resource_name.to_sym
|
|
43
|
+
@metrics[key] ||= build(@doc.deref(entry(key)))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Indirect reference to that font, for the appearance /Resources. Fonts
|
|
47
|
+
# absent from /DR share one registered Helvetica; a /DR font stored as a
|
|
48
|
+
# direct dictionary is promoted to an indirect object once, not once per
|
|
49
|
+
# widget.
|
|
50
|
+
def reference(resource_name)
|
|
51
|
+
key = resource_name.to_sym
|
|
52
|
+
@references[key] ||=
|
|
53
|
+
begin
|
|
54
|
+
found = entry(key)
|
|
55
|
+
found ? @doc.ref_for(found) : fallback
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def dr_fonts
|
|
62
|
+
@dr_fonts ||=
|
|
63
|
+
begin
|
|
64
|
+
dr = @doc.deref(@acroform[:DR])
|
|
65
|
+
fonts = dr.is_a?(Hash) ? @doc.deref(dr[:Font]) : nil
|
|
66
|
+
fonts.is_a?(Hash) ? fonts : {}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def entry(key)
|
|
71
|
+
dr_fonts[key]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def fallback
|
|
75
|
+
@fallback ||=
|
|
76
|
+
@doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build(dict)
|
|
80
|
+
return Metrics.font_for('') unless dict.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
name = base_font_name(dict)
|
|
83
|
+
standard = Metrics.standard_font(name)
|
|
84
|
+
ascender, descender, top, bottom = vertical(dict, standard)
|
|
85
|
+
Metrics::Font.new(widths(dict) || Metrics.widths_for(name), ascender, descender, top, bottom)
|
|
86
|
+
.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def base_font_name(dict)
|
|
90
|
+
@doc.deref(dict[:BaseFont]).to_s.sub(SUBSET_PREFIX, '')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The font's own glyph widths, laid out the way Metrics tables are
|
|
94
|
+
# (index = code - 32), or nil when the dictionary does not supply usable
|
|
95
|
+
# ones. Only the codes acrofill can emit are read, so a /Widths array of
|
|
96
|
+
# any declared length costs the same.
|
|
97
|
+
def widths(dict)
|
|
98
|
+
first = @doc.deref(dict[:FirstChar])
|
|
99
|
+
declared = @doc.deref(dict[:Widths])
|
|
100
|
+
return nil unless first.is_a?(Integer) && declared.is_a?(Array)
|
|
101
|
+
|
|
102
|
+
low = [Metrics::FIRST_CODE - first, 0].max
|
|
103
|
+
high = [Metrics::LAST_CODE - first, declared.size - 1].min
|
|
104
|
+
return nil if high < low
|
|
105
|
+
|
|
106
|
+
table = Array.new(Metrics::LAST_CODE - Metrics::FIRST_CODE + 1, OUT_OF_RANGE_WIDTH)
|
|
107
|
+
(low..high).each do |index|
|
|
108
|
+
width = number(declared[index])
|
|
109
|
+
table[first + index - Metrics::FIRST_CODE] = width if width
|
|
110
|
+
end
|
|
111
|
+
table
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def vertical(dict, standard)
|
|
115
|
+
descriptor = @doc.deref(dict[:FontDescriptor])
|
|
116
|
+
descriptor = nil unless descriptor.is_a?(Hash)
|
|
117
|
+
bottom, top = font_bbox(descriptor)
|
|
118
|
+
[
|
|
119
|
+
(descriptor && number(descriptor[:Ascent])) || standard&.ascender || DEFAULT_ASCENDER,
|
|
120
|
+
(descriptor && number(descriptor[:Descent])) || standard&.descender || DEFAULT_DESCENDER,
|
|
121
|
+
top || standard&.bbox_top || DEFAULT_BBOX_TOP,
|
|
122
|
+
bottom || standard&.bbox_bottom || DEFAULT_BBOX_BOTTOM
|
|
123
|
+
]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# [lower y, upper y] of /FontBBox, or [nil, nil] when it is unusable.
|
|
127
|
+
def font_bbox(descriptor)
|
|
128
|
+
box = descriptor && @doc.deref(descriptor[:FontBBox])
|
|
129
|
+
return [nil, nil] unless box.is_a?(Array) && box.size == 4
|
|
130
|
+
|
|
131
|
+
[number(box[1]), number(box[3])]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Font dictionaries are template data: every scalar may be indirect, and
|
|
135
|
+
# a non-finite one would poison the geometry it feeds.
|
|
136
|
+
def number(raw)
|
|
137
|
+
value = @doc.deref(raw)
|
|
138
|
+
return nil unless value.is_a?(Numeric)
|
|
139
|
+
|
|
140
|
+
value = value.to_f
|
|
141
|
+
value.finite? ? value : nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/acrofill/form.rb
CHANGED
|
@@ -4,7 +4,6 @@ module Acrofill
|
|
|
4
4
|
# The interactive form of a document: field lookup by fully-qualified
|
|
5
5
|
# name, value filling with appearance regeneration, and flattening.
|
|
6
6
|
class Form
|
|
7
|
-
HIDDEN_FLAG = 2
|
|
8
7
|
MULTILINE_FLAG = 1 << 12
|
|
9
8
|
PUSHBUTTON_FLAG = 1 << 16
|
|
10
9
|
|
|
@@ -46,7 +45,7 @@ module Acrofill
|
|
|
46
45
|
|
|
47
46
|
case field_type(groups.first[:node])
|
|
48
47
|
when :Btn then fill_button(groups, value)
|
|
49
|
-
when :Tx, :Ch, nil then groups
|
|
48
|
+
when :Tx, :Ch, nil then fill_text_groups(groups, value)
|
|
50
49
|
else false # signatures and unknown types are left untouched
|
|
51
50
|
end
|
|
52
51
|
end
|
|
@@ -54,8 +53,7 @@ module Acrofill
|
|
|
54
53
|
# Stamps every visible widget appearance into its page's content and
|
|
55
54
|
# removes the interactive layer, like pdftk's `output ... flatten`.
|
|
56
55
|
def flatten!
|
|
57
|
-
@doc.
|
|
58
|
-
@doc.root.delete(:AcroForm)
|
|
56
|
+
Flattener.new(@doc).flatten!
|
|
59
57
|
end
|
|
60
58
|
|
|
61
59
|
private
|
|
@@ -76,11 +74,11 @@ module Acrofill
|
|
|
76
74
|
flags.is_a?(Integer) ? flags : 0
|
|
77
75
|
end
|
|
78
76
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
# Every group sharing this name is filled, so the walk must not
|
|
78
|
+
# short-circuit on the first one.
|
|
79
|
+
def fill_text_groups(groups, value)
|
|
80
|
+
groups.each { |group| fill_text(group, value) }
|
|
81
|
+
true
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
def fill_text(group, value)
|
|
@@ -91,11 +89,14 @@ module Acrofill
|
|
|
91
89
|
multiline = field_flags(node).anybits?(MULTILINE_FLAG)
|
|
92
90
|
group[:widgets].each do |widget|
|
|
93
91
|
widget.delete(:AS)
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
ap_ref = @appearance.build(node, widget, value, multiline: multiline) unless value.empty?
|
|
93
|
+
# An unusable geometry yields no appearance; dropping /AP is still
|
|
94
|
+
# required, or the widget would keep rendering the *previous* value
|
|
95
|
+
# while /V already holds the new one.
|
|
96
|
+
if ap_ref
|
|
97
|
+
widget[:AP] = { N: ap_ref }
|
|
96
98
|
else
|
|
97
|
-
|
|
98
|
-
widget[:AP] = { N: ap_ref } if ap_ref
|
|
99
|
+
widget.delete(:AP)
|
|
99
100
|
end
|
|
100
101
|
end
|
|
101
102
|
true
|
|
@@ -222,132 +223,5 @@ module Acrofill
|
|
|
222
223
|
|
|
223
224
|
"\xFE\xFF".b + value.encode('UTF-16BE').b
|
|
224
225
|
end
|
|
225
|
-
|
|
226
|
-
def flatten_page(page)
|
|
227
|
-
annot_refs = @doc.deref(page[:Annots])
|
|
228
|
-
annot_refs = [] unless annot_refs.is_a?(Array)
|
|
229
|
-
annots = annot_refs.map { |a| [a, @doc.deref(a)] }
|
|
230
|
-
widgets, others = annots.partition { |_ref, dict| dict.is_a?(Hash) && dict[:Subtype] == :Widget }
|
|
231
|
-
return if widgets.empty?
|
|
232
|
-
|
|
233
|
-
stamps = []
|
|
234
|
-
widgets.each do |_ref, widget|
|
|
235
|
-
stamp = stamp_operations(page, widget)
|
|
236
|
-
stamps << stamp if stamp
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
unless stamps.empty?
|
|
240
|
-
wrap = ->(bytes) { @doc.add(StreamObject.new({}, bytes.b)) }
|
|
241
|
-
derefed = @doc.deref(page[:Contents])
|
|
242
|
-
contents = (derefed.is_a?(Array) ? derefed : [page[:Contents]]).compact
|
|
243
|
-
contents = contents.map { |stream| @doc.ref_for(stream) }
|
|
244
|
-
page[:Contents] = [wrap.call("q\n"), *contents, wrap.call("\nQ\n#{stamps.join("\n")}\n")]
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
remaining = others.map(&:first)
|
|
248
|
-
if remaining.empty?
|
|
249
|
-
page.delete(:Annots)
|
|
250
|
-
else
|
|
251
|
-
page[:Annots] = remaining
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Returns content-stream operations placing the widget's normal
|
|
256
|
-
# appearance onto the page, or nil when there is nothing to draw.
|
|
257
|
-
# Implements the appearance-box algorithm of PDF 32000 §12.5.5: the
|
|
258
|
-
# form's /Matrix is applied to its BBox, and the resulting extent is
|
|
259
|
-
# mapped onto the annotation rectangle.
|
|
260
|
-
def stamp_operations(page, widget)
|
|
261
|
-
return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
|
|
262
|
-
|
|
263
|
-
ap_ref = normal_appearance(widget)
|
|
264
|
-
xobject = @doc.deref(ap_ref)
|
|
265
|
-
dict = xobject.is_a?(StreamObject) ? xobject.dict : xobject&.hash
|
|
266
|
-
return nil unless dict.is_a?(Hash)
|
|
267
|
-
|
|
268
|
-
bbox = normalize_box(@doc.deref(dict[:BBox]))
|
|
269
|
-
rect = normalize_box(@doc.deref(widget[:Rect]))
|
|
270
|
-
return nil unless bbox && rect
|
|
271
|
-
|
|
272
|
-
# Appearance streams are form XObjects, but /Type and /Subtype are
|
|
273
|
-
# sometimes omitted; /Do requires them.
|
|
274
|
-
dict[:Type] ||= :XObject
|
|
275
|
-
dict[:Subtype] ||= :Form
|
|
276
|
-
|
|
277
|
-
llx, lly, urx, ury = rect
|
|
278
|
-
bx0, by0, bx1, by1 = transformed_bbox(bbox, @doc.deref(dict[:Matrix]))
|
|
279
|
-
bw = bx1 - bx0
|
|
280
|
-
bh = by1 - by0
|
|
281
|
-
return nil if bw <= 0 || bh <= 0
|
|
282
|
-
|
|
283
|
-
sx = (urx - llx) / bw
|
|
284
|
-
sy = (ury - lly) / bh
|
|
285
|
-
name = register_xobject(page, ap_ref)
|
|
286
|
-
matrix = [sx, 0, 0, sy, llx - (bx0 * sx), lly - (by0 * sy)]
|
|
287
|
-
ops = matrix.map { |n| Serializer.format_number(n.to_f) }
|
|
288
|
-
"q #{ops.join(' ')} cm /#{name} Do Q"
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Bounding box of the (already normalized) BBox corners after the
|
|
292
|
-
# form's /Matrix (identity when absent or malformed).
|
|
293
|
-
def transformed_bbox(bbox, matrix)
|
|
294
|
-
x0, y0, x1, y1 = bbox
|
|
295
|
-
matrix = matrix.map { |m| @doc.deref(m) } if matrix.is_a?(Array)
|
|
296
|
-
return [x0, y0, x1, y1] unless matrix.is_a?(Array) && matrix.size == 6 &&
|
|
297
|
-
matrix.all?(Numeric)
|
|
298
|
-
|
|
299
|
-
a, b, c, d, e, f = matrix.map(&:to_f)
|
|
300
|
-
xs = []
|
|
301
|
-
ys = []
|
|
302
|
-
[[x0, y0], [x1, y0], [x0, y1], [x1, y1]].each do |x, y|
|
|
303
|
-
xs << ((a * x) + (c * y) + e)
|
|
304
|
-
ys << ((b * x) + (d * y) + f)
|
|
305
|
-
end
|
|
306
|
-
[xs.min, ys.min, xs.max, ys.max]
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def normal_appearance(widget)
|
|
310
|
-
ap = @doc.deref(widget[:AP])
|
|
311
|
-
return nil unless ap.is_a?(Hash)
|
|
312
|
-
|
|
313
|
-
normal = ap[:N]
|
|
314
|
-
states = @doc.deref(normal)
|
|
315
|
-
if states.is_a?(Hash) && !states.is_a?(PDF::Reader::Stream)
|
|
316
|
-
# Pick the widget's current state; without /AS default to /Off
|
|
317
|
-
# (never an arbitrary "on" appearance for an unset checkbox).
|
|
318
|
-
state = @doc.deref(widget[:AS])
|
|
319
|
-
state = :Off unless state.is_a?(Symbol) && states.key?(state)
|
|
320
|
-
normal = states[state]
|
|
321
|
-
end
|
|
322
|
-
normal
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Derefs each element (array entries may legally be indirect objects)
|
|
326
|
-
# and returns [llx, lly, urx, ury], or nil when the box is not four
|
|
327
|
-
# numbers.
|
|
328
|
-
def normalize_box(box)
|
|
329
|
-
return nil unless box.is_a?(Array) && box.size == 4
|
|
330
|
-
|
|
331
|
-
nums = box.map { |n| @doc.deref(n) }
|
|
332
|
-
return nil unless nums.all?(Numeric)
|
|
333
|
-
|
|
334
|
-
xs = [nums[0].to_f, nums[2].to_f].sort
|
|
335
|
-
ys = [nums[1].to_f, nums[3].to_f].sort
|
|
336
|
-
[xs[0], ys[0], xs[1], ys[1]]
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def register_xobject(page, ap_ref)
|
|
340
|
-
resources = @doc.deref(page[:Resources]) || @doc.inherited_value(page, :Resources)
|
|
341
|
-
resources = @doc.deref(resources) || {}
|
|
342
|
-
resources = resources.dup
|
|
343
|
-
xobjects = (@doc.deref(resources[:XObject]) || {}).dup
|
|
344
|
-
|
|
345
|
-
@stamp_counter = (@stamp_counter || 0) + 1
|
|
346
|
-
name = :"AcrofillAP#{@stamp_counter}"
|
|
347
|
-
xobjects[name] = @doc.ref_for(ap_ref)
|
|
348
|
-
resources[:XObject] = xobjects
|
|
349
|
-
page[:Resources] = resources
|
|
350
|
-
name
|
|
351
|
-
end
|
|
352
226
|
end
|
|
353
227
|
end
|
data/lib/acrofill/metrics.rb
CHANGED
|
@@ -1,29 +1,238 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
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
3
|
module Acrofill
|
|
4
|
+
# Glyph widths (1/1000 em) for the standard-14 text fonts, indexed by
|
|
5
|
+
# WinAnsiEncoding code minus 32 (appearance streams are written as
|
|
6
|
+
# Windows-1252 bytes against a /WinAnsiEncoding font, so the byte emitted
|
|
7
|
+
# is the byte measured). Extracted from Adobe AFM metrics.
|
|
8
|
+
#
|
|
9
|
+
# Only six width tables are stored: the oblique cuts of Helvetica have the
|
|
10
|
+
# same widths as the upright face, and the whole Courier family is
|
|
11
|
+
# monospaced. Vertical metrics (ascender, descender, FontBBox extent) are
|
|
12
|
+
# per cut, since they differ where widths do not.
|
|
6
13
|
module Metrics
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
DEFAULT_WIDTH = 556
|
|
15
|
+
COURIER_WIDTH = 600
|
|
16
|
+
FIRST_CODE = 32
|
|
17
|
+
LAST_CODE = 255
|
|
18
|
+
WINDOWS_1252 = Encoding::Windows_1252
|
|
19
|
+
|
|
20
|
+
# The standard-14 text cuts, indexed by bold + 2 * italic per family.
|
|
21
|
+
CUTS = {
|
|
22
|
+
helvetica: %w[Helvetica Helvetica-Bold Helvetica-Oblique Helvetica-BoldOblique].freeze,
|
|
23
|
+
times: %w[Times-Roman Times-Bold Times-Italic Times-BoldItalic].freeze,
|
|
24
|
+
courier: %w[Courier Courier-Bold Courier-Oblique Courier-BoldOblique].freeze
|
|
16
25
|
}.freeze
|
|
26
|
+
# Family and weight hints for BaseFont names outside the standard 14.
|
|
27
|
+
SERIF = /times|roman|serif|georgia|garamond/
|
|
28
|
+
SANS = /sans/
|
|
29
|
+
FIXED = /courier|mono/
|
|
30
|
+
BOLD = /bold|black|heavy/
|
|
31
|
+
ITALIC = /italic|oblique/
|
|
17
32
|
|
|
18
|
-
|
|
33
|
+
TABLES = {
|
|
34
|
+
'Helvetica' => [
|
|
35
|
+
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556,
|
|
36
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667,
|
|
37
|
+
667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722,
|
|
38
|
+
667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500,
|
|
39
|
+
556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278,
|
|
40
|
+
556, 500, 722, 500, 500, 500, 334, 260, 334, 584, nil, 556, nil, 222, 556, 333, 1000,
|
|
41
|
+
556, 556, 333, 1000, 667, 333, 1000, nil, 611, nil, nil, 222, 222, 333, 333, 350, 556,
|
|
42
|
+
1000, 333, 1000, 500, 333, 944, nil, 500, 667, 278, 333, 556, 556, 556, 556, 260, 556,
|
|
43
|
+
333, 737, 370, 556, 584, 333, 737, 333, 400, 584, 333, 333, 333, 556, 537, 278, 333,
|
|
44
|
+
333, 365, 556, 834, 834, 834, 611, 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667,
|
|
45
|
+
667, 667, 278, 278, 278, 278, 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722,
|
|
46
|
+
722, 722, 667, 667, 611, 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556,
|
|
47
|
+
278, 278, 278, 278, 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556,
|
|
48
|
+
500, 556, 500
|
|
49
|
+
].freeze,
|
|
50
|
+
'Helvetica-Bold' => [
|
|
51
|
+
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556,
|
|
52
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722,
|
|
53
|
+
722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722,
|
|
54
|
+
667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556,
|
|
55
|
+
611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333,
|
|
56
|
+
611, 556, 778, 556, 556, 500, 389, 280, 389, 584, nil, 556, nil, 278, 556, 500, 1000,
|
|
57
|
+
556, 556, 333, 1000, 667, 333, 1000, nil, 611, nil, nil, 278, 278, 500, 500, 350, 556,
|
|
58
|
+
1000, 333, 1000, 556, 333, 944, nil, 500, 667, 278, 333, 556, 556, 556, 556, 280, 556,
|
|
59
|
+
333, 737, 370, 556, 584, 333, 737, 333, 400, 584, 333, 333, 333, 611, 556, 278, 333,
|
|
60
|
+
333, 365, 556, 834, 834, 834, 611, 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667,
|
|
61
|
+
667, 667, 278, 278, 278, 278, 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722,
|
|
62
|
+
722, 722, 667, 667, 611, 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556,
|
|
63
|
+
278, 278, 278, 278, 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611,
|
|
64
|
+
556, 611, 556
|
|
65
|
+
].freeze,
|
|
66
|
+
'Times-Roman' => [
|
|
67
|
+
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, 500,
|
|
68
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, 921, 722,
|
|
69
|
+
667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667,
|
|
70
|
+
556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, 333, 444, 500, 444,
|
|
71
|
+
500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, 500, 500, 333, 389, 278,
|
|
72
|
+
500, 500, 722, 500, 500, 444, 480, 200, 480, 541, nil, 500, nil, 333, 500, 444, 1000,
|
|
73
|
+
500, 500, 333, 1000, 556, 333, 889, nil, 611, nil, nil, 333, 333, 444, 444, 350, 500,
|
|
74
|
+
1000, 333, 980, 389, 333, 722, nil, 444, 722, 250, 333, 500, 500, 500, 500, 200, 500,
|
|
75
|
+
333, 760, 276, 500, 564, 333, 760, 333, 400, 564, 300, 300, 333, 500, 453, 250, 333,
|
|
76
|
+
300, 310, 500, 750, 750, 750, 444, 722, 722, 722, 722, 722, 722, 889, 667, 611, 611,
|
|
77
|
+
611, 611, 333, 333, 333, 333, 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722,
|
|
78
|
+
722, 722, 722, 556, 500, 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444,
|
|
79
|
+
278, 278, 278, 278, 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500,
|
|
80
|
+
500, 500, 500
|
|
81
|
+
].freeze,
|
|
82
|
+
'Times-Bold' => [
|
|
83
|
+
250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, 500,
|
|
84
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, 930, 722,
|
|
85
|
+
667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, 611, 778, 722,
|
|
86
|
+
556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, 333, 500, 556, 444,
|
|
87
|
+
556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, 556, 556, 444, 389, 333,
|
|
88
|
+
556, 500, 722, 500, 500, 444, 394, 220, 394, 520, nil, 500, nil, 333, 500, 500, 1000,
|
|
89
|
+
500, 500, 333, 1000, 556, 333, 1000, nil, 667, nil, nil, 333, 333, 500, 500, 350, 500,
|
|
90
|
+
1000, 333, 1000, 389, 333, 722, nil, 444, 722, 250, 333, 500, 500, 500, 500, 220, 500,
|
|
91
|
+
333, 747, 300, 500, 570, 333, 747, 333, 400, 570, 300, 300, 333, 556, 540, 250, 333,
|
|
92
|
+
300, 330, 500, 750, 750, 750, 500, 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667,
|
|
93
|
+
667, 667, 389, 389, 389, 389, 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722,
|
|
94
|
+
722, 722, 722, 611, 556, 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444,
|
|
95
|
+
278, 278, 278, 278, 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556,
|
|
96
|
+
500, 556, 500
|
|
97
|
+
].freeze,
|
|
98
|
+
'Times-Italic' => [
|
|
99
|
+
250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, 500,
|
|
100
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, 920, 611,
|
|
101
|
+
611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, 611, 722, 611,
|
|
102
|
+
500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, 333, 500, 500, 444,
|
|
103
|
+
500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, 500, 500, 389, 389, 278,
|
|
104
|
+
500, 444, 667, 444, 444, 389, 400, 275, 400, 541, nil, 500, nil, 333, 500, 556, 889,
|
|
105
|
+
500, 500, 333, 1000, 500, 333, 944, nil, 556, nil, nil, 333, 333, 556, 556, 350, 500,
|
|
106
|
+
889, 333, 980, 389, 333, 667, nil, 389, 556, 250, 389, 500, 500, 500, 500, 275, 500,
|
|
107
|
+
333, 760, 276, 500, 675, 333, 760, 333, 400, 675, 300, 300, 333, 500, 523, 250, 333,
|
|
108
|
+
300, 310, 500, 750, 750, 750, 500, 611, 611, 611, 611, 611, 611, 889, 667, 611, 611,
|
|
109
|
+
611, 611, 333, 333, 333, 333, 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722,
|
|
110
|
+
722, 722, 556, 611, 500, 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444,
|
|
111
|
+
278, 278, 278, 278, 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500,
|
|
112
|
+
444, 500, 444
|
|
113
|
+
].freeze,
|
|
114
|
+
'Times-BoldItalic' => [
|
|
115
|
+
250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, 500,
|
|
116
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, 832, 667,
|
|
117
|
+
667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, 611, 722, 667,
|
|
118
|
+
556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, 333, 500, 500, 444,
|
|
119
|
+
500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, 500, 500, 389, 389, 278,
|
|
120
|
+
556, 444, 667, 500, 444, 389, 348, 220, 348, 570, nil, 500, nil, 333, 500, 500, 1000,
|
|
121
|
+
500, 500, 333, 1000, 556, 333, 944, nil, 611, nil, nil, 333, 333, 500, 500, 350, 500,
|
|
122
|
+
1000, 333, 1000, 389, 333, 722, nil, 389, 611, 250, 389, 500, 500, 500, 500, 220, 500,
|
|
123
|
+
333, 747, 266, 500, 606, 333, 747, 333, 400, 570, 300, 300, 333, 576, 500, 250, 333,
|
|
124
|
+
300, 300, 500, 750, 750, 750, 500, 667, 667, 667, 667, 667, 667, 944, 667, 667, 667,
|
|
125
|
+
667, 667, 389, 389, 389, 389, 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722,
|
|
126
|
+
722, 722, 611, 611, 500, 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444,
|
|
127
|
+
278, 278, 278, 278, 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556,
|
|
128
|
+
444, 500, 444
|
|
129
|
+
].freeze,
|
|
130
|
+
'Courier' => Array.new(LAST_CODE - FIRST_CODE + 1, COURIER_WIDTH).freeze
|
|
131
|
+
}.freeze
|
|
132
|
+
|
|
133
|
+
# Cuts that share a width table with another cut.
|
|
134
|
+
WIDTHS = TABLES.merge(
|
|
135
|
+
'Helvetica-Oblique' => TABLES['Helvetica'],
|
|
136
|
+
'Helvetica-BoldOblique' => TABLES['Helvetica-Bold'],
|
|
137
|
+
'Courier-Bold' => TABLES['Courier'],
|
|
138
|
+
'Courier-Oblique' => TABLES['Courier'],
|
|
139
|
+
'Courier-BoldOblique' => TABLES['Courier']
|
|
140
|
+
).freeze
|
|
141
|
+
|
|
142
|
+
# [ascender, descender, FontBBox top, FontBBox bottom] in 1/1000 em,
|
|
143
|
+
# from the same Adobe AFM data as the widths. pdftk places baselines
|
|
144
|
+
# from the ascender and spaces multiline rows by the FontBBox extent,
|
|
145
|
+
# so these drive vertical geometry.
|
|
146
|
+
VERTICAL = {
|
|
147
|
+
'Helvetica' => [718, -207, 931, -225].freeze,
|
|
148
|
+
'Helvetica-Bold' => [718, -207, 962, -228].freeze,
|
|
149
|
+
'Helvetica-Oblique' => [718, -207, 931, -225].freeze,
|
|
150
|
+
'Helvetica-BoldOblique' => [718, -207, 962, -228].freeze,
|
|
151
|
+
'Times-Roman' => [683, -217, 898, -218].freeze,
|
|
152
|
+
'Times-Bold' => [683, -217, 935, -218].freeze,
|
|
153
|
+
'Times-Italic' => [683, -217, 883, -217].freeze,
|
|
154
|
+
'Times-BoldItalic' => [683, -217, 921, -218].freeze,
|
|
155
|
+
'Courier' => [629, -157, 805, -250].freeze,
|
|
156
|
+
'Courier-Bold' => [629, -157, 801, -250].freeze,
|
|
157
|
+
'Courier-Oblique' => [629, -157, 805, -250].freeze,
|
|
158
|
+
'Courier-BoldOblique' => [629, -157, 801, -250].freeze
|
|
159
|
+
}.freeze
|
|
160
|
+
|
|
161
|
+
# Everything the appearance code needs about one resolved face.
|
|
162
|
+
Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom) do
|
|
163
|
+
def ascent(size) = ascender * size / 1000.0
|
|
19
164
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
165
|
+
def descent(size) = -descender * size / 1000.0
|
|
166
|
+
|
|
167
|
+
# Distance from the box top to the first baseline, and between rows.
|
|
168
|
+
def top(size) = bbox_top * size / 1000.0
|
|
169
|
+
|
|
170
|
+
def line_height(size) = (bbox_top - bbox_bottom) * size / 1000.0
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
FONTS = VERTICAL.to_h do |name, (asc, desc, top, bottom)|
|
|
174
|
+
[name, Font.new(WIDTHS[name], asc, desc, top, bottom).freeze]
|
|
175
|
+
end.freeze
|
|
176
|
+
|
|
177
|
+
# Width of +str+ at +size+ points. +widths+ is a table from #widths_for,
|
|
178
|
+
# or a BaseFont name (resolved here, at the cost of the lookup).
|
|
179
|
+
def self.string_width(str, widths, size)
|
|
180
|
+
widths = widths_for(widths) unless widths.is_a?(Array)
|
|
181
|
+
str = to_win_ansi(str)
|
|
182
|
+
units = str.each_byte.sum do |code|
|
|
183
|
+
(code >= FIRST_CODE && widths[code - FIRST_CODE]) || DEFAULT_WIDTH
|
|
25
184
|
end
|
|
26
185
|
units * size / 1000.0
|
|
27
186
|
end
|
|
187
|
+
|
|
188
|
+
# Metrics for a BaseFont name. Standard-14 names resolve directly;
|
|
189
|
+
# anything else (ArialMT, TimesNewRomanPS-BoldMT, a template's embedded
|
|
190
|
+
# face) is classified by family and weight, which is far closer than
|
|
191
|
+
# treating everything as Helvetica.
|
|
192
|
+
def self.font_for(base_font)
|
|
193
|
+
FONTS.fetch(canonical_name(base_font))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.widths_for(base_font)
|
|
197
|
+
font_for(base_font).widths
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Metrics for a name that *is* one of the standard 14, or nil. Unlike
|
|
201
|
+
# #font_for this does not classify: a template's own face only inherits
|
|
202
|
+
# standard-14 vertical metrics when it actually names one.
|
|
203
|
+
def self.standard_font(base_font)
|
|
204
|
+
FONTS[base_font.to_s]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Maps any BaseFont name onto one of the twelve standard-14 text cuts.
|
|
208
|
+
def self.canonical_name(base_font)
|
|
209
|
+
name = base_font.to_s
|
|
210
|
+
return name if FONTS.key?(name)
|
|
211
|
+
|
|
212
|
+
lower = name.downcase
|
|
213
|
+
family = if FIXED.match?(lower) then :courier
|
|
214
|
+
elsif serif?(lower) then :times
|
|
215
|
+
else :helvetica
|
|
216
|
+
end
|
|
217
|
+
CUTS[family][weight_index(lower)]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.serif?(lower)
|
|
221
|
+
SERIF.match?(lower) && !SANS.match?(lower)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def self.weight_index(lower)
|
|
225
|
+
(BOLD.match?(lower) ? 1 : 0) + (ITALIC.match?(lower) ? 2 : 0)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Appearance text is already Windows-1252; anything else is converted so
|
|
229
|
+
# that measuring and rendering agree byte for byte.
|
|
230
|
+
def self.to_win_ansi(str)
|
|
231
|
+
return str if str.encoding == WINDOWS_1252
|
|
232
|
+
|
|
233
|
+
str.encode(WINDOWS_1252, invalid: :replace, undef: :replace, replace: '?')
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private_class_method :serif?, :weight_index, :to_win_ansi
|
|
28
237
|
end
|
|
29
238
|
end
|
data/lib/acrofill/version.rb
CHANGED
data/lib/acrofill.rb
CHANGED
|
@@ -5,7 +5,9 @@ require_relative 'acrofill/document'
|
|
|
5
5
|
require_relative 'acrofill/metrics'
|
|
6
6
|
require_relative 'acrofill/serializer'
|
|
7
7
|
require_relative 'acrofill/writer'
|
|
8
|
+
require_relative 'acrofill/fonts'
|
|
8
9
|
require_relative 'acrofill/appearance'
|
|
10
|
+
require_relative 'acrofill/flattener'
|
|
9
11
|
require_relative 'acrofill/form'
|
|
10
12
|
require_relative 'acrofill/filler'
|
|
11
13
|
require_relative 'acrofill/template'
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- stiig
|
|
@@ -37,6 +37,8 @@ files:
|
|
|
37
37
|
- lib/acrofill/appearance.rb
|
|
38
38
|
- lib/acrofill/document.rb
|
|
39
39
|
- lib/acrofill/filler.rb
|
|
40
|
+
- lib/acrofill/flattener.rb
|
|
41
|
+
- lib/acrofill/fonts.rb
|
|
40
42
|
- lib/acrofill/form.rb
|
|
41
43
|
- lib/acrofill/metrics.rb
|
|
42
44
|
- lib/acrofill/serializer.rb
|