acrofill 0.2.0 → 0.4.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 +83 -1
- data/README.md +36 -7
- data/lib/acrofill/appearance.rb +100 -81
- data/lib/acrofill/flattener.rb +8 -1
- data/lib/acrofill/fonts.rb +194 -0
- data/lib/acrofill/form.rb +21 -2
- data/lib/acrofill/metrics.rb +127 -18
- data/lib/acrofill/version.rb +1 -1
- data/lib/acrofill.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 633b173570db39e5b16714fd23e558a6832ed36f38a549802eb9dcda64967854
|
|
4
|
+
data.tar.gz: 26d94b282728f297c7c61d51ed817749d8435ecdd6dd4962a11942ac0af30fe9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5aad0c14c51e0d9e28a2c4c09b03d840b01c91ef83908ea0d31674f12ff0e4da8c33e7918600ba8ef409313a4faf0e8cfad30b325b3e23fa5c2786643fcdc97
|
|
7
|
+
data.tar.gz: 18be3697b53f7fe00d6d815a8cd7e0755ca3eef63eccc02e4f9dba181c4a14df648b05289e10ba9bf92b4e0ff62032ff9b1f42a6464a4ed3b75cd2c09b693261
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-07-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Comb fields (`/Ff` bit 25 with `/MaxLen`) lay each character out centered
|
|
15
|
+
in its own cell, `/Q` choosing which run of cells the value occupies,
|
|
16
|
+
matching pdftk cell for cell. They previously rendered as plain text.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- A value is now written in the codes its font actually draws it with. A
|
|
21
|
+
`/DR` font whose `/Encoding` carries a `/Differences` array moves glyphs
|
|
22
|
+
to other codes; writing the raw bytes drew whatever glyph happened to sit
|
|
23
|
+
there — a font mapping "A" to code 90 rendered "AZ" as "ZA". Widths
|
|
24
|
+
follow the emitted code, so measurement and rendering stay in step.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Flattening no longer stamps widgets a viewer would not display: `/F`
|
|
29
|
+
Hidden (bit 2) was already dropped, and NoView (bit 6, "not on screen",
|
|
30
|
+
PDF 32000 §12.5.3) now is too. pdftk stamps both, but burning in a
|
|
31
|
+
widget the template author concealed makes hidden values permanently
|
|
32
|
+
visible, so parity loses here. Widgets that are merely non-printing are
|
|
33
|
+
still stamped: they are what the viewer shows.
|
|
34
|
+
|
|
35
|
+
## [0.3.0] - 2026-07-27
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Single-line baselines now match pdftk when the text is taller than the
|
|
40
|
+
field, which real forms hit routinely (a 12pt `/DA` in a 10.8pt-high box
|
|
41
|
+
is common). Centering is bounded on both sides: the ascender is kept
|
|
42
|
+
inside the box and the baseline never drops below the box floor. This was
|
|
43
|
+
the last geometry difference on a 551-field sample of real templates.
|
|
44
|
+
- Text is now measured with the metrics of the font it is actually drawn
|
|
45
|
+
with. A template that embeds its own face declares `/Widths` and a
|
|
46
|
+
`/FontDescriptor`, and those were ignored in favour of standard-14
|
|
47
|
+
tables — which put centered and right-aligned values as much as tens of
|
|
48
|
+
points away from where pdftk puts them (43pt on a 300pt-wide field in
|
|
49
|
+
one measured case). `/Widths` now drives glyph widths, `/FontDescriptor`
|
|
50
|
+
`/Ascent` the baseline and its `/FontBBox` the multiline row spacing,
|
|
51
|
+
falling back to the standard-14 tables only when the dictionary is
|
|
52
|
+
silent. This is the layout most real-world forms hit, since almost all
|
|
53
|
+
of them embed a subset face.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- Vertical geometry is now font-aware and matches pdftk-java 3.3.3 exactly.
|
|
58
|
+
Baselines are placed from the font's own AFM ascender instead of a fixed
|
|
59
|
+
Helvetica value (Times sat 0.18pt low, Courier 0.45pt), and multiline rows
|
|
60
|
+
are spaced by the font's `FontBBox` extent with pdftk's 1pt top offset
|
|
61
|
+
instead of a flat `1.15 * size` leading.
|
|
62
|
+
- `Acrofill::Metrics` exposes `.font_for`, returning widths plus ascender,
|
|
63
|
+
descender and `FontBBox` for one of the twelve standard-14 text cuts.
|
|
64
|
+
Vertical metrics are stored per cut, since Courier-Bold and Times-Italic
|
|
65
|
+
differ there even where their widths do not.
|
|
66
|
+
- Font resolution moved out of `Appearance` into `Acrofill::Fonts`, which
|
|
67
|
+
owns the `/DR /Font` dictionary: metrics for a `/DA` resource name and
|
|
68
|
+
the reference a generated appearance points at.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- `benchmark/geometry_diff.rb` compares acrofill's appearance streams with
|
|
73
|
+
pdftk's field by field on your own templates.
|
|
74
|
+
- `spec/pdftk_parity_spec.rb` pins alignment, baseline and multiline row
|
|
75
|
+
geometry — for standard-14 faces, template-supplied metrics, and text
|
|
76
|
+
taller than its field — to numbers measured from pdftk's own output.
|
|
77
|
+
|
|
78
|
+
### Known differences from pdftk
|
|
79
|
+
|
|
80
|
+
- Auto-sized fields (`0 Tf`): pdftk picks a font-dependent size that fills
|
|
81
|
+
the box (16.33pt in a 20pt box for Helvetica, and 20.73pt — taller than
|
|
82
|
+
the box — for Courier), with a hard 4pt floor. Acrofill keeps its own
|
|
83
|
+
`min(height * 0.66, 12pt)` and is not going to reproduce that.
|
|
84
|
+
- Values too wide for the field: acrofill shrinks the font to fit, pdftk
|
|
85
|
+
keeps the size and clips.
|
|
86
|
+
- Non-ASCII values: pdftk writes UTF-8 bytes into a `/WinAnsiEncoding`
|
|
87
|
+
font, which renders as mojibake; acrofill writes Windows-1252, so the
|
|
88
|
+
text is correct and the measured width differs accordingly.
|
|
89
|
+
|
|
10
90
|
## [0.2.0] - 2026-07-27
|
|
11
91
|
|
|
12
92
|
### Fixed
|
|
@@ -87,7 +167,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
87
167
|
- `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
|
|
88
168
|
`field_names`).
|
|
89
169
|
|
|
90
|
-
[Unreleased]: https://github.com/stiig/acrofill/compare/v0.
|
|
170
|
+
[Unreleased]: https://github.com/stiig/acrofill/compare/v0.4.0...HEAD
|
|
171
|
+
[0.4.0]: https://github.com/stiig/acrofill/compare/v0.3.0...v0.4.0
|
|
172
|
+
[0.3.0]: https://github.com/stiig/acrofill/compare/v0.2.0...v0.3.0
|
|
91
173
|
[0.2.0]: https://github.com/stiig/acrofill/compare/v0.1.2...v0.2.0
|
|
92
174
|
[0.1.2]: https://github.com/stiig/acrofill/compare/v0.1.1...v0.1.2
|
|
93
175
|
[0.1.1]: https://github.com/stiig/acrofill/compare/v0.1.0...v0.1.1
|
data/README.md
CHANGED
|
@@ -75,6 +75,33 @@ 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
|
+
Flattened output was checked the same way: on those four forms every
|
|
95
|
+
stamped fragment lands where pdftk stamps it, bar the fields covered by the
|
|
96
|
+
deliberate differences below.
|
|
97
|
+
|
|
98
|
+
Four differences are deliberate: acrofill shrinks an overlong value to fit
|
|
99
|
+
where pdftk clips it; it writes Windows-1252 for non-ASCII values where
|
|
100
|
+
pdftk emits UTF-8 bytes into a WinAnsi font (which renders as mojibake); it
|
|
101
|
+
caps auto-sized (`0 Tf`) text at 12pt where pdftk scales it to fill the box;
|
|
102
|
+
and it drops widgets flagged Hidden or NoView when flattening instead of
|
|
103
|
+
burning them into the page. See the changelog for the measured numbers.
|
|
104
|
+
|
|
78
105
|
## Performance
|
|
79
106
|
|
|
80
107
|
Because Acrofill runs in-process, it avoids the JVM (or C++ process)
|
|
@@ -163,13 +190,16 @@ Supported:
|
|
|
163
190
|
- Text fields (`/Tx`) — hierarchical names (`parent.kid`), inherited
|
|
164
191
|
`/DA`, alignment via `/Q` (left/center/right), auto font size (`0 Tf`),
|
|
165
192
|
shrink-to-fit for overflowing values, multiline fields (`/Ff` bit 13)
|
|
166
|
-
with word wrapping, and
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
193
|
+
with word wrapping, and text measured with the metrics of the font it is
|
|
194
|
+
drawn with: a template's own `/Widths` and `/FontDescriptor` when the
|
|
195
|
+
face carries them (as embedded subsets do), otherwise WinAnsi tables for
|
|
196
|
+
all standard-14 cuts — widths, ascender and `FontBBox`, so baselines and
|
|
197
|
+
row spacing follow the actual face. A `/BaseFont` naming no standard cut
|
|
198
|
+
(`ArialMT`, `TimesNewRomanPS-BoldMT`) is classified by family and weight
|
|
199
|
+
rather than all measured as Helvetica.
|
|
171
200
|
- Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
|
|
172
201
|
using the template's own appearance states.
|
|
202
|
+
- Comb fields (`/Ff` bit 25) — one character centered per `/MaxLen` cell.
|
|
173
203
|
- Choice fields (`/Ch`) — value set and rendered like text.
|
|
174
204
|
- Flattening — every visible widget appearance is stamped into the page
|
|
175
205
|
content; widget annotations and the AcroForm dictionary are removed.
|
|
@@ -180,8 +210,7 @@ Not supported (rejected or ignored, never a hard crash):
|
|
|
180
210
|
|
|
181
211
|
- Encrypted documents (raise `Acrofill::Error`), XFA forms, digital
|
|
182
212
|
signatures, JavaScript actions.
|
|
183
|
-
- Push buttons;
|
|
184
|
-
dropped on fill.
|
|
213
|
+
- Push buttons; rich text (`/RV`) is dropped on fill.
|
|
185
214
|
- Glyphs outside Windows-1252 in generated appearances (stored values
|
|
186
215
|
keep full Unicode; unrenderable glyphs appear as `?`).
|
|
187
216
|
|
data/lib/acrofill/appearance.rb
CHANGED
|
@@ -5,66 +5,94 @@ 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
|
+
|
|
11
|
+
# The drawable area of one widget: its size and the field's /Q.
|
|
12
|
+
Box = Struct.new(:width, :height, :align)
|
|
10
13
|
# Colour-setting operators allowed in a /DA string, and their operand counts.
|
|
11
14
|
COLOR_OP_ARITY = { 'g' => 1, 'rg' => 3, 'k' => 4 }.freeze
|
|
12
15
|
|
|
13
16
|
def initialize(doc, acroform)
|
|
14
17
|
@doc = doc
|
|
15
18
|
@acroform = acroform
|
|
19
|
+
@fonts = Fonts.new(doc, acroform)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
# Returns a Reference to the new appearance XObject, or nil when the
|
|
19
23
|
# widget geometry is unusable.
|
|
20
|
-
def build(field_node, widget, value, multiline: false)
|
|
24
|
+
def build(field_node, widget, value, multiline: false, comb: nil)
|
|
21
25
|
rect = normalized_rect(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
|
|
22
26
|
return nil unless rect
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return nil if width <= 0 || height <= 0
|
|
28
|
+
box = Box.new(rect[2] - rect[0], rect[3] - rect[1], alignment(field_node))
|
|
29
|
+
return nil if box.width <= 0 || box.height <= 0
|
|
27
30
|
|
|
28
31
|
font_name, size, color_ops = parse_da(field_node)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
font = @fonts.metrics(font_name)
|
|
33
|
+
size, body = draw(value, font, size, box, multiline: multiline, comb: comb)
|
|
34
|
+
|
|
35
|
+
@doc.add(StreamObject.new(appearance_dict(font_name, box),
|
|
36
|
+
content(font_name, size, color_ops, body).b))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def fmt(num)
|
|
42
|
+
Serializer.format_number(num.to_f)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The operators drawing +value+, and the point size they were laid out
|
|
46
|
+
# at — auto-sizing and shrink-to-fit both adjust the /DA size.
|
|
47
|
+
def draw(value, font, size, box, multiline:, comb:)
|
|
48
|
+
if multiline
|
|
49
|
+
size = (size <= 0 ? 12.0 : size).clamp(2.0, 144.0)
|
|
50
|
+
[size, multiline_body(value, font, size, box)]
|
|
51
|
+
elsif comb
|
|
52
|
+
size = auto_size(size, box.height).clamp(2.0, 144.0)
|
|
53
|
+
[size, comb_body(font.encode(printable_text(value)), font, size, box, comb)]
|
|
54
|
+
else
|
|
55
|
+
text = font.encode(printable_text(value))
|
|
56
|
+
size = shrink_to_fit(text, font, auto_size(size, box.height), box.width)
|
|
57
|
+
[size, "#{fmt(line_x(text, font, size, box))} #{fmt(baseline(box.height, font, size))} Td\n" \
|
|
58
|
+
"(#{escape_literal(text)}) Tj\n"]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A /DA size of 0 means "fit the box"; acrofill caps that at 12pt.
|
|
63
|
+
def auto_size(size, height)
|
|
64
|
+
size.zero? ? [height * 0.66, 12.0].min : size
|
|
65
|
+
end
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
def content(font_name, size, color_ops, body)
|
|
68
|
+
stream = +"/Tx BMC\nq\nBT\n"
|
|
69
|
+
stream << "#{color_ops}\n" unless color_ops.empty?
|
|
70
|
+
stream << "/#{font_name} #{fmt(size)} Tf\n"
|
|
71
|
+
stream << body
|
|
72
|
+
stream << "ET\nQ\nEMC\n"
|
|
73
|
+
end
|
|
53
74
|
|
|
54
|
-
|
|
75
|
+
def appearance_dict(font_name, box)
|
|
76
|
+
{
|
|
55
77
|
Type: :XObject,
|
|
56
78
|
Subtype: :Form,
|
|
57
79
|
FormType: 1,
|
|
58
|
-
BBox: [0, 0, width, height],
|
|
59
|
-
Resources: { Font: { font_name.to_sym =>
|
|
80
|
+
BBox: [0, 0, box.width, box.height],
|
|
81
|
+
Resources: { Font: { font_name.to_sym => @fonts.reference(font_name) } }
|
|
60
82
|
}
|
|
61
|
-
@doc.add(StreamObject.new(dict, content.b))
|
|
62
83
|
end
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
# Where pdftk puts a single line's baseline: the ascent box centered in
|
|
86
|
+
# the field, but never so low that the descender leaves the box, never so
|
|
87
|
+
# high that the ascender does, and never below the box floor. The last
|
|
88
|
+
# two only bind when the text is taller than the field it sits in, which
|
|
89
|
+
# real forms do hit — a 12pt /DA in a 10.8pt-high field is common.
|
|
90
|
+
def baseline(height, font, size)
|
|
91
|
+
ascent = font.ascent(size)
|
|
92
|
+
highest = height - ascent # baseline putting the ascender at the box top
|
|
93
|
+
return 0.0 if highest <= 0 # the glyphs are taller than the box
|
|
65
94
|
|
|
66
|
-
|
|
67
|
-
Serializer.format_number(num.to_f)
|
|
95
|
+
[highest / 2.0, font.descent(size)].max.clamp(0.0, highest)
|
|
68
96
|
end
|
|
69
97
|
|
|
70
98
|
# /Q (0 left, 1 center, 2 right), inheritable and possibly indirect.
|
|
@@ -73,29 +101,49 @@ module Acrofill
|
|
|
73
101
|
align.is_a?(Integer) ? align : 0
|
|
74
102
|
end
|
|
75
103
|
|
|
76
|
-
def line_x(text,
|
|
77
|
-
text_width = Metrics.string_width(text, widths, size)
|
|
78
|
-
case align
|
|
79
|
-
when 1 then [(width - text_width) / 2.0, PADDING].max
|
|
80
|
-
when 2 then [width - PADDING - text_width, PADDING].max
|
|
104
|
+
def line_x(text, font, size, box)
|
|
105
|
+
text_width = Metrics.string_width(text, font.widths, size)
|
|
106
|
+
case box.align
|
|
107
|
+
when 1 then [(box.width - text_width) / 2.0, PADDING].max
|
|
108
|
+
when 2 then [box.width - PADDING - text_width, PADDING].max
|
|
81
109
|
else PADDING
|
|
82
110
|
end
|
|
83
111
|
end
|
|
84
112
|
|
|
113
|
+
# A comb field divides its box into /MaxLen equal cells and centers one
|
|
114
|
+
# character in each; /Q picks the run of cells the value occupies. Each
|
|
115
|
+
# glyph is positioned absolutely, the way pdftk writes it.
|
|
116
|
+
def comb_body(text, font, size, box, cells)
|
|
117
|
+
cell = box.width / cells.to_f
|
|
118
|
+
chars = text.chars
|
|
119
|
+
first = case box.align
|
|
120
|
+
when 1 then [(cells - chars.size) / 2, 0].max
|
|
121
|
+
when 2 then [cells - chars.size, 0].max
|
|
122
|
+
else 0
|
|
123
|
+
end
|
|
124
|
+
ty = fmt(baseline(box.height, font, size))
|
|
125
|
+
chars.each_with_index.map do |char, index|
|
|
126
|
+
x = ((first + index + 0.5) * cell) - (Metrics.string_width(char, font.widths, size) / 2.0)
|
|
127
|
+
"1 0 0 1 #{fmt(x)} #{ty} Tm\n(#{escape_literal(char)}) Tj\n"
|
|
128
|
+
end.join
|
|
129
|
+
end
|
|
130
|
+
|
|
85
131
|
# Greedy word wrap, top-down, honouring explicit line breaks. Lines
|
|
86
132
|
# that would fall below the box are clipped by the BBox.
|
|
87
|
-
def multiline_body(value,
|
|
88
|
-
max_width = width - (2 * PADDING)
|
|
133
|
+
def multiline_body(value, font, size, box)
|
|
134
|
+
max_width = box.width - (2 * PADDING)
|
|
89
135
|
lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
|
|
90
|
-
wrap_line(printable_text(paragraph),
|
|
136
|
+
wrap_line(font.encode(printable_text(paragraph)), font, size, max_width)
|
|
91
137
|
end
|
|
92
138
|
|
|
93
|
-
|
|
94
|
-
|
|
139
|
+
# pdftk spaces rows by the font's FontBBox extent and drops the first
|
|
140
|
+
# baseline by that extent from the top of the box.
|
|
141
|
+
leading = font.line_height(size)
|
|
142
|
+
first_y = box.height - font.top(size) + TOP_OFFSET
|
|
95
143
|
body = "#{fmt(leading)} TL\n"
|
|
96
144
|
previous_x = 0.0
|
|
97
145
|
lines.each_with_index do |line, index|
|
|
98
|
-
x = line_x(line,
|
|
146
|
+
x = line_x(line, font, size, box)
|
|
99
147
|
body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
|
|
100
148
|
body << "(#{escape_literal(line)}) Tj\nT*\n"
|
|
101
149
|
previous_x = x
|
|
@@ -105,12 +153,12 @@ module Acrofill
|
|
|
105
153
|
|
|
106
154
|
# Greedy wrap. Line and space widths are accumulated incrementally so
|
|
107
155
|
# the cost is O(total characters), not O(words * line-length).
|
|
108
|
-
def wrap_line(text,
|
|
109
|
-
space = Metrics.string_width(' ', widths, size)
|
|
156
|
+
def wrap_line(text, font, size, max_width)
|
|
157
|
+
space = Metrics.string_width(' ', font.widths, size)
|
|
110
158
|
lines = ['']
|
|
111
159
|
so_far = [0.0]
|
|
112
160
|
text.split.each do |word|
|
|
113
|
-
word_width = Metrics.string_width(word, widths, size)
|
|
161
|
+
word_width = Metrics.string_width(word, font.widths, size)
|
|
114
162
|
if lines.last.empty?
|
|
115
163
|
lines[-1] = word
|
|
116
164
|
so_far[-1] = word_width
|
|
@@ -182,42 +230,13 @@ module Acrofill
|
|
|
182
230
|
|
|
183
231
|
# Fixed sizes that overflow the box are scaled down so the whole value
|
|
184
232
|
# stays visible (Acrobat-style best-fit; pdftk would clip instead).
|
|
185
|
-
def shrink_to_fit(text,
|
|
233
|
+
def shrink_to_fit(text, font, size, width)
|
|
186
234
|
max_width = width - (2 * PADDING)
|
|
187
|
-
text_width = Metrics.string_width(text, widths, size)
|
|
235
|
+
text_width = Metrics.string_width(text, font.widths, size)
|
|
188
236
|
size *= max_width / text_width if text_width > max_width && text_width.positive?
|
|
189
237
|
size.clamp(2.0, 144.0)
|
|
190
238
|
end
|
|
191
239
|
|
|
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_fonts ||=
|
|
196
|
-
begin
|
|
197
|
-
dr = @doc.deref(@acroform[:DR])
|
|
198
|
-
fonts = dr.is_a?(Hash) ? @doc.deref(dr[:Font]) : nil
|
|
199
|
-
fonts.is_a?(Hash) ? fonts : {}
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def base_font_for(resource_name)
|
|
204
|
-
font = @doc.deref(dr_fonts[resource_name.to_sym])
|
|
205
|
-
base = font.is_a?(Hash) ? @doc.deref(font[:BaseFont]).to_s : ''
|
|
206
|
-
base.sub(/\A[A-Z]{6}\+/, '') # drop the subset prefix (ABCDEF+Arial)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def font_ref(resource_name)
|
|
210
|
-
entry = dr_fonts[resource_name.to_sym]
|
|
211
|
-
entry ? @doc.ref_for(entry) : fallback_font
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# Font not present in /DR: register one plain Helvetica and share it
|
|
215
|
-
# across every appearance built from this form.
|
|
216
|
-
def fallback_font
|
|
217
|
-
@fallback_font ||=
|
|
218
|
-
@doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
240
|
def printable_text(value)
|
|
222
241
|
value.to_s.gsub(/[[:space:]]+/, ' ').strip
|
|
223
242
|
.encode('Windows-1252', invalid: :replace, undef: :replace, replace: '?')
|
data/lib/acrofill/flattener.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Acrofill
|
|
|
6
6
|
# comes from the template, so each one is validated before use.
|
|
7
7
|
class Flattener
|
|
8
8
|
HIDDEN_FLAG = 2
|
|
9
|
+
NOVIEW_FLAG = 32
|
|
10
|
+
INVISIBLE = HIDDEN_FLAG | NOVIEW_FLAG
|
|
9
11
|
|
|
10
12
|
def initialize(doc)
|
|
11
13
|
@doc = doc
|
|
@@ -61,7 +63,12 @@ module Acrofill
|
|
|
61
63
|
# form's /Matrix is applied to its BBox, and the resulting extent is
|
|
62
64
|
# mapped onto the annotation rectangle.
|
|
63
65
|
def stamp_operations(page, widget)
|
|
64
|
-
|
|
66
|
+
# Flattening produces the document as it is displayed, so a widget the
|
|
67
|
+
# viewer would not show is dropped rather than burned in: /F Hidden,
|
|
68
|
+
# and NoView, which means "not on screen" (PDF 32000 §12.5.3). pdftk
|
|
69
|
+
# stamps both, but stamping Hidden makes values the template author
|
|
70
|
+
# concealed permanently visible, so parity loses here.
|
|
71
|
+
return nil if annotation_flags(widget).anybits?(INVISIBLE)
|
|
65
72
|
|
|
66
73
|
ap_ref = normal_appearance(widget)
|
|
67
74
|
xobject = @doc.deref(ap_ref)
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
# codes /Encoding /Differences remap the value's bytes
|
|
19
|
+
#
|
|
20
|
+
# The standard-14 fallbacks apply only to a BaseFont that literally names
|
|
21
|
+
# one of the fourteen; a face merely *resembling* one (ArialMT and friends)
|
|
22
|
+
# gets pdftk's generic defaults vertically, while its widths still fall back
|
|
23
|
+
# to the closest standard table, there being nothing better to measure with.
|
|
24
|
+
class Fonts
|
|
25
|
+
DEFAULT_ASCENDER = 800
|
|
26
|
+
DEFAULT_DESCENDER = -200
|
|
27
|
+
DEFAULT_BBOX_TOP = 900
|
|
28
|
+
DEFAULT_BBOX_BOTTOM = -200
|
|
29
|
+
# A code outside /FirstChar../LastChar draws as zero-width: pdftk does
|
|
30
|
+
# not consult /MissingWidth (verified — setting it changes nothing).
|
|
31
|
+
OUT_OF_RANGE_WIDTH = 0
|
|
32
|
+
SUBSET_PREFIX = /\A[A-Z]{6}\+/
|
|
33
|
+
IDENTITY = Array.new(256) { |code| code }.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(doc, acroform)
|
|
36
|
+
@doc = doc
|
|
37
|
+
@acroform = acroform
|
|
38
|
+
@metrics = {}
|
|
39
|
+
@references = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Metrics::Font for the font a /DA string names.
|
|
43
|
+
def metrics(resource_name)
|
|
44
|
+
key = resource_name.to_sym
|
|
45
|
+
@metrics[key] ||= build(@doc.deref(entry(key)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Indirect reference to that font, for the appearance /Resources. Fonts
|
|
49
|
+
# absent from /DR share one registered Helvetica; a /DR font stored as a
|
|
50
|
+
# direct dictionary is promoted to an indirect object once, not once per
|
|
51
|
+
# widget.
|
|
52
|
+
def reference(resource_name)
|
|
53
|
+
key = resource_name.to_sym
|
|
54
|
+
@references[key] ||=
|
|
55
|
+
begin
|
|
56
|
+
found = entry(key)
|
|
57
|
+
found ? @doc.ref_for(found) : fallback
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def dr_fonts
|
|
64
|
+
@dr_fonts ||=
|
|
65
|
+
begin
|
|
66
|
+
dr = @doc.deref(@acroform[:DR])
|
|
67
|
+
fonts = dr.is_a?(Hash) ? @doc.deref(dr[:Font]) : nil
|
|
68
|
+
fonts.is_a?(Hash) ? fonts : {}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def entry(key)
|
|
73
|
+
dr_fonts[key]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fallback
|
|
77
|
+
@fallback ||=
|
|
78
|
+
@doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build(dict)
|
|
82
|
+
return Metrics.font_for('') unless dict.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
name = base_font_name(dict)
|
|
85
|
+
standard = Metrics.standard_font(name)
|
|
86
|
+
ascender, descender, top, bottom = vertical(dict, standard)
|
|
87
|
+
Metrics::Font.new(widths(dict) || Metrics.widths_for(name),
|
|
88
|
+
ascender, descender, top, bottom, remap(dict)).freeze
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A 256-entry code translation table when /Encoding /Differences moves
|
|
92
|
+
# glyphs to codes other than their WinAnsi ones, else nil. Without it a
|
|
93
|
+
# value is drawn with whatever glyphs happen to sit at its bytes: a font
|
|
94
|
+
# that puts "A" at code 90 would render "AZ" as "ZA".
|
|
95
|
+
def remap(dict)
|
|
96
|
+
differences = differences(dict)
|
|
97
|
+
return nil if differences.empty?
|
|
98
|
+
|
|
99
|
+
by_code = {}
|
|
100
|
+
Metrics::WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
|
|
101
|
+
by_code[index + Metrics::FIRST_CODE] = glyph if glyph
|
|
102
|
+
end
|
|
103
|
+
differences.each { |code, glyph| by_code[code] = glyph }
|
|
104
|
+
|
|
105
|
+
at = {}
|
|
106
|
+
by_code.each { |code, glyph| at[glyph] ||= code }
|
|
107
|
+
table = Array.new(256) { |code| code }
|
|
108
|
+
Metrics::WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
|
|
109
|
+
code = glyph && at[glyph]
|
|
110
|
+
table[index + Metrics::FIRST_CODE] = code if code
|
|
111
|
+
end
|
|
112
|
+
table == IDENTITY ? nil : table.freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# /Differences is a flat array where an integer restarts the code
|
|
116
|
+
# counter and each following name takes the next code (PDF 32000
|
|
117
|
+
# §9.6.6.1).
|
|
118
|
+
def differences(dict)
|
|
119
|
+
encoding = @doc.deref(dict[:Encoding])
|
|
120
|
+
return {} unless encoding.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
entries = @doc.deref(encoding[:Differences])
|
|
123
|
+
return {} unless entries.is_a?(Array)
|
|
124
|
+
|
|
125
|
+
code = nil
|
|
126
|
+
entries.each_with_object({}) do |raw, result|
|
|
127
|
+
item = @doc.deref(raw)
|
|
128
|
+
case item
|
|
129
|
+
when Integer then code = item
|
|
130
|
+
when Symbol
|
|
131
|
+
next unless code&.between?(0, 255)
|
|
132
|
+
|
|
133
|
+
result[code] = item # a glyph name, matching Metrics::WIN_ANSI_GLYPHS
|
|
134
|
+
code += 1
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def base_font_name(dict)
|
|
140
|
+
@doc.deref(dict[:BaseFont]).to_s.sub(SUBSET_PREFIX, '')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The font's own glyph widths, laid out the way Metrics tables are
|
|
144
|
+
# (index = code - 32), or nil when the dictionary does not supply usable
|
|
145
|
+
# ones. Only the codes acrofill can emit are read, so a /Widths array of
|
|
146
|
+
# any declared length costs the same.
|
|
147
|
+
def widths(dict)
|
|
148
|
+
first = @doc.deref(dict[:FirstChar])
|
|
149
|
+
declared = @doc.deref(dict[:Widths])
|
|
150
|
+
return nil unless first.is_a?(Integer) && declared.is_a?(Array)
|
|
151
|
+
|
|
152
|
+
low = [Metrics::FIRST_CODE - first, 0].max
|
|
153
|
+
high = [Metrics::LAST_CODE - first, declared.size - 1].min
|
|
154
|
+
return nil if high < low
|
|
155
|
+
|
|
156
|
+
table = Array.new(Metrics::LAST_CODE - Metrics::FIRST_CODE + 1, OUT_OF_RANGE_WIDTH)
|
|
157
|
+
(low..high).each do |index|
|
|
158
|
+
width = number(declared[index])
|
|
159
|
+
table[first + index - Metrics::FIRST_CODE] = width if width
|
|
160
|
+
end
|
|
161
|
+
table
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def vertical(dict, standard)
|
|
165
|
+
descriptor = @doc.deref(dict[:FontDescriptor])
|
|
166
|
+
descriptor = nil unless descriptor.is_a?(Hash)
|
|
167
|
+
bottom, top = font_bbox(descriptor)
|
|
168
|
+
[
|
|
169
|
+
(descriptor && number(descriptor[:Ascent])) || standard&.ascender || DEFAULT_ASCENDER,
|
|
170
|
+
(descriptor && number(descriptor[:Descent])) || standard&.descender || DEFAULT_DESCENDER,
|
|
171
|
+
top || standard&.bbox_top || DEFAULT_BBOX_TOP,
|
|
172
|
+
bottom || standard&.bbox_bottom || DEFAULT_BBOX_BOTTOM
|
|
173
|
+
]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# [lower y, upper y] of /FontBBox, or [nil, nil] when it is unusable.
|
|
177
|
+
def font_bbox(descriptor)
|
|
178
|
+
box = descriptor && @doc.deref(descriptor[:FontBBox])
|
|
179
|
+
return [nil, nil] unless box.is_a?(Array) && box.size == 4
|
|
180
|
+
|
|
181
|
+
[number(box[1]), number(box[3])]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Font dictionaries are template data: every scalar may be indirect, and
|
|
185
|
+
# a non-finite one would poison the geometry it feeds.
|
|
186
|
+
def number(raw)
|
|
187
|
+
value = @doc.deref(raw)
|
|
188
|
+
return nil unless value.is_a?(Numeric)
|
|
189
|
+
|
|
190
|
+
value = value.to_f
|
|
191
|
+
value.finite? ? value : nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
data/lib/acrofill/form.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Acrofill
|
|
|
6
6
|
class Form
|
|
7
7
|
MULTILINE_FLAG = 1 << 12
|
|
8
8
|
PUSHBUTTON_FLAG = 1 << 16
|
|
9
|
+
COMB_FLAG = 1 << 24
|
|
9
10
|
|
|
10
11
|
Field = Struct.new(:name, :type, :value, :states, keyword_init: true)
|
|
11
12
|
|
|
@@ -81,15 +82,23 @@ module Acrofill
|
|
|
81
82
|
true
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
def build_appearance(node, widget, value, multiline, comb)
|
|
86
|
+
return nil if value.empty?
|
|
87
|
+
|
|
88
|
+
@appearance.build(node, widget, value, multiline: multiline, comb: comb)
|
|
89
|
+
end
|
|
90
|
+
|
|
84
91
|
def fill_text(group, value)
|
|
85
92
|
node = group[:node]
|
|
86
93
|
node[:V] = pdf_text_string(value)
|
|
87
94
|
node.delete(:RV)
|
|
88
95
|
node.delete(:I)
|
|
89
|
-
|
|
96
|
+
flags = field_flags(node)
|
|
97
|
+
multiline = flags.anybits?(MULTILINE_FLAG)
|
|
98
|
+
comb = comb_cells(node, flags, multiline)
|
|
90
99
|
group[:widgets].each do |widget|
|
|
91
100
|
widget.delete(:AS)
|
|
92
|
-
ap_ref =
|
|
101
|
+
ap_ref = build_appearance(node, widget, value, multiline, comb)
|
|
93
102
|
# An unusable geometry yields no appearance; dropping /AP is still
|
|
94
103
|
# required, or the widget would keep rendering the *previous* value
|
|
95
104
|
# while /V already holds the new one.
|
|
@@ -102,6 +111,16 @@ module Acrofill
|
|
|
102
111
|
true
|
|
103
112
|
end
|
|
104
113
|
|
|
114
|
+
# How many cells a comb field lays its value out in, or nil when the
|
|
115
|
+
# field is not a comb. The flag only means anything alongside /MaxLen
|
|
116
|
+
# and on a single-line field (PDF 32000 §12.7.4.3).
|
|
117
|
+
def comb_cells(node, flags, multiline)
|
|
118
|
+
return nil if multiline || !flags.anybits?(COMB_FLAG)
|
|
119
|
+
|
|
120
|
+
cells = @doc.deref(@doc.inherited_value(node, :MaxLen))
|
|
121
|
+
cells.is_a?(Integer) && cells.positive? ? cells : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
105
124
|
# Checkboxes and radio groups carry per-state appearance streams, so
|
|
106
125
|
# filling only selects a state: /V on the field, /AS on each widget.
|
|
107
126
|
# The value may be a state name ("Yes"), or anything non-empty when the
|
data/lib/acrofill/metrics.rb
CHANGED
|
@@ -6,18 +6,25 @@ module Acrofill
|
|
|
6
6
|
# Windows-1252 bytes against a /WinAnsiEncoding font, so the byte emitted
|
|
7
7
|
# is the byte measured). Extracted from Adobe AFM metrics.
|
|
8
8
|
#
|
|
9
|
-
# Only six tables are stored: the oblique cuts of Helvetica have the
|
|
10
|
-
# widths as the upright face, and the whole Courier family is
|
|
11
|
-
#
|
|
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.
|
|
12
13
|
module Metrics
|
|
13
14
|
DEFAULT_WIDTH = 556
|
|
14
15
|
COURIER_WIDTH = 600
|
|
15
16
|
FIRST_CODE = 32
|
|
16
17
|
LAST_CODE = 255
|
|
17
18
|
WINDOWS_1252 = Encoding::Windows_1252
|
|
19
|
+
# Encodings whose bytes are already the codes the font is drawn with.
|
|
20
|
+
BYTE_ENCODINGS = [Encoding::Windows_1252, Encoding::BINARY].freeze
|
|
18
21
|
|
|
19
|
-
#
|
|
20
|
-
|
|
22
|
+
# The standard-14 text cuts, indexed by bold + 2 * italic per family.
|
|
23
|
+
CUTS = {
|
|
24
|
+
helvetica: %w[Helvetica Helvetica-Bold Helvetica-Oblique Helvetica-BoldOblique].freeze,
|
|
25
|
+
times: %w[Times-Roman Times-Bold Times-Italic Times-BoldItalic].freeze,
|
|
26
|
+
courier: %w[Courier Courier-Bold Courier-Oblique Courier-BoldOblique].freeze
|
|
27
|
+
}.freeze
|
|
21
28
|
# Family and weight hints for BaseFont names outside the standard 14.
|
|
22
29
|
SERIF = /times|roman|serif|georgia|garamond/
|
|
23
30
|
SANS = /sans/
|
|
@@ -25,7 +32,7 @@ module Acrofill
|
|
|
25
32
|
BOLD = /bold|black|heavy/
|
|
26
33
|
ITALIC = /italic|oblique/
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
TABLES = {
|
|
29
36
|
'Helvetica' => [
|
|
30
37
|
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556,
|
|
31
38
|
556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667,
|
|
@@ -125,6 +132,90 @@ module Acrofill
|
|
|
125
132
|
'Courier' => Array.new(LAST_CODE - FIRST_CODE + 1, COURIER_WIDTH).freeze
|
|
126
133
|
}.freeze
|
|
127
134
|
|
|
135
|
+
# Cuts that share a width table with another cut.
|
|
136
|
+
WIDTHS = TABLES.merge(
|
|
137
|
+
'Helvetica-Oblique' => TABLES['Helvetica'],
|
|
138
|
+
'Helvetica-BoldOblique' => TABLES['Helvetica-Bold'],
|
|
139
|
+
'Courier-Bold' => TABLES['Courier'],
|
|
140
|
+
'Courier-Oblique' => TABLES['Courier'],
|
|
141
|
+
'Courier-BoldOblique' => TABLES['Courier']
|
|
142
|
+
).freeze
|
|
143
|
+
|
|
144
|
+
# [ascender, descender, FontBBox top, FontBBox bottom] in 1/1000 em,
|
|
145
|
+
# from the same Adobe AFM data as the widths. pdftk places baselines
|
|
146
|
+
# from the ascender and spaces multiline rows by the FontBBox extent,
|
|
147
|
+
# so these drive vertical geometry.
|
|
148
|
+
VERTICAL = {
|
|
149
|
+
'Helvetica' => [718, -207, 931, -225].freeze,
|
|
150
|
+
'Helvetica-Bold' => [718, -207, 962, -228].freeze,
|
|
151
|
+
'Helvetica-Oblique' => [718, -207, 931, -225].freeze,
|
|
152
|
+
'Helvetica-BoldOblique' => [718, -207, 962, -228].freeze,
|
|
153
|
+
'Times-Roman' => [683, -217, 898, -218].freeze,
|
|
154
|
+
'Times-Bold' => [683, -217, 935, -218].freeze,
|
|
155
|
+
'Times-Italic' => [683, -217, 883, -217].freeze,
|
|
156
|
+
'Times-BoldItalic' => [683, -217, 921, -218].freeze,
|
|
157
|
+
'Courier' => [629, -157, 805, -250].freeze,
|
|
158
|
+
'Courier-Bold' => [629, -157, 801, -250].freeze,
|
|
159
|
+
'Courier-Oblique' => [629, -157, 805, -250].freeze,
|
|
160
|
+
'Courier-BoldOblique' => [629, -157, 801, -250].freeze
|
|
161
|
+
}.freeze
|
|
162
|
+
|
|
163
|
+
# WinAnsiEncoding glyph names, index = code - 32. Used to re-encode a
|
|
164
|
+
# value for a font whose /Encoding remaps codes: the value is turned
|
|
165
|
+
# into glyph names, then into the codes *this* font draws them at.
|
|
166
|
+
WIN_ANSI_GLYPHS = [
|
|
167
|
+
:space, :exclam, :quotedbl, :numbersign, :dollar, :percent, :ampersand, :quotesingle,
|
|
168
|
+
:parenleft, :parenright, :asterisk, :plus, :comma, :hyphen, :period, :slash, :zero,
|
|
169
|
+
:one, :two, :three, :four, :five, :six, :seven, :eight, :nine, :colon, :semicolon,
|
|
170
|
+
:less, :equal, :greater, :question, :at, :A, :B, :C, :D, :E, :F, :G, :H, :I, :J, :K,
|
|
171
|
+
:L, :M, :N, :O, :P, :Q, :R, :S, :T, :U, :V, :W, :X, :Y, :Z, :bracketleft, :backslash,
|
|
172
|
+
:bracketright, :asciicircum, :underscore, :grave, :a, :b, :c, :d, :e, :f, :g, :h, :i,
|
|
173
|
+
:j, :k, :l, :m, :n, :o, :p, :q, :r, :s, :t, :u, :v, :w, :x, :y, :z, :braceleft, :bar,
|
|
174
|
+
:braceright, :asciitilde, :controlDEL, :Euro, nil, :quotesinglbase, :florin,
|
|
175
|
+
:quotedblbase, :ellipsis, :dagger, :daggerdbl, :circumflex, :perthousand, :Scaron,
|
|
176
|
+
:guilsinglleft, :OE, nil, :Zcaron, nil, nil, :quoteleft, :quoteright, :quotedblleft,
|
|
177
|
+
:quotedblright, :bullet, :endash, :emdash, :tilde, :trademark, :scaron,
|
|
178
|
+
:guilsinglright, :oe, nil, :zcaron, :Ydieresis, :space, :exclamdown, :cent, :sterling,
|
|
179
|
+
:currency, :yen, :brokenbar, :section, :dieresis, :copyright, :ordfeminine,
|
|
180
|
+
:guillemotleft, :logicalnot, :hyphen, :registered, :macron, :degree, :plusminus,
|
|
181
|
+
:twosuperior, :threesuperior, :acute, :mu, :paragraph, :periodcentered, :cedilla,
|
|
182
|
+
:onesuperior, :ordmasculine, :guillemotright, :onequarter, :onehalf, :threequarters,
|
|
183
|
+
:questiondown, :Agrave, :Aacute, :Acircumflex, :Atilde, :Adieresis, :Aring, :AE,
|
|
184
|
+
:Ccedilla, :Egrave, :Eacute, :Ecircumflex, :Edieresis, :Igrave, :Iacute, :Icircumflex,
|
|
185
|
+
:Idieresis, :Eth, :Ntilde, :Ograve, :Oacute, :Ocircumflex, :Otilde, :Odieresis,
|
|
186
|
+
:multiply, :Oslash, :Ugrave, :Uacute, :Ucircumflex, :Udieresis, :Yacute, :Thorn,
|
|
187
|
+
:germandbls, :agrave, :aacute, :acircumflex, :atilde, :adieresis, :aring, :ae,
|
|
188
|
+
:ccedilla, :egrave, :eacute, :ecircumflex, :edieresis, :igrave, :iacute, :icircumflex,
|
|
189
|
+
:idieresis, :eth, :ntilde, :ograve, :oacute, :ocircumflex, :otilde, :odieresis,
|
|
190
|
+
:divide, :oslash, :ugrave, :uacute, :ucircumflex, :udieresis, :yacute, :thorn,
|
|
191
|
+
:ydieresis
|
|
192
|
+
].freeze
|
|
193
|
+
|
|
194
|
+
# Everything the appearance code needs about one resolved face. +remap+
|
|
195
|
+
# is a 256-entry code translation table, or nil when the font draws
|
|
196
|
+
# WinAnsi codes as they are.
|
|
197
|
+
Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom, :remap) do
|
|
198
|
+
# Windows-1252 text as the byte codes this font draws it with.
|
|
199
|
+
def encode(text)
|
|
200
|
+
return text unless remap
|
|
201
|
+
|
|
202
|
+
text.b.each_byte.map { |code| remap[code] }.pack('C*')
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ascent(size) = ascender * size / 1000.0
|
|
206
|
+
|
|
207
|
+
def descent(size) = -descender * size / 1000.0
|
|
208
|
+
|
|
209
|
+
# Distance from the box top to the first baseline, and between rows.
|
|
210
|
+
def top(size) = bbox_top * size / 1000.0
|
|
211
|
+
|
|
212
|
+
def line_height(size) = (bbox_top - bbox_bottom) * size / 1000.0
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
FONTS = VERTICAL.to_h do |name, (asc, desc, top, bottom)|
|
|
216
|
+
[name, Font.new(WIDTHS[name], asc, desc, top, bottom).freeze]
|
|
217
|
+
end.freeze
|
|
218
|
+
|
|
128
219
|
# Width of +str+ at +size+ points. +widths+ is a table from #widths_for,
|
|
129
220
|
# or a BaseFont name (resolved here, at the cost of the lookup).
|
|
130
221
|
def self.string_width(str, widths, size)
|
|
@@ -136,19 +227,36 @@ module Acrofill
|
|
|
136
227
|
units * size / 1000.0
|
|
137
228
|
end
|
|
138
229
|
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
230
|
+
# Metrics for a BaseFont name. Standard-14 names resolve directly;
|
|
231
|
+
# anything else (ArialMT, TimesNewRomanPS-BoldMT, a template's embedded
|
|
232
|
+
# face) is classified by family and weight, which is far closer than
|
|
233
|
+
# treating everything as Helvetica.
|
|
234
|
+
def self.font_for(base_font)
|
|
235
|
+
FONTS.fetch(canonical_name(base_font))
|
|
236
|
+
end
|
|
237
|
+
|
|
143
238
|
def self.widths_for(base_font)
|
|
239
|
+
font_for(base_font).widths
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Metrics for a name that *is* one of the standard 14, or nil. Unlike
|
|
243
|
+
# #font_for this does not classify: a template's own face only inherits
|
|
244
|
+
# standard-14 vertical metrics when it actually names one.
|
|
245
|
+
def self.standard_font(base_font)
|
|
246
|
+
FONTS[base_font.to_s]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Maps any BaseFont name onto one of the twelve standard-14 text cuts.
|
|
250
|
+
def self.canonical_name(base_font)
|
|
144
251
|
name = base_font.to_s
|
|
145
|
-
return
|
|
252
|
+
return name if FONTS.key?(name)
|
|
146
253
|
|
|
147
254
|
lower = name.downcase
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
255
|
+
family = if FIXED.match?(lower) then :courier
|
|
256
|
+
elsif serif?(lower) then :times
|
|
257
|
+
else :helvetica
|
|
258
|
+
end
|
|
259
|
+
CUTS[family][weight_index(lower)]
|
|
152
260
|
end
|
|
153
261
|
|
|
154
262
|
def self.serif?(lower)
|
|
@@ -159,10 +267,11 @@ module Acrofill
|
|
|
159
267
|
(BOLD.match?(lower) ? 1 : 0) + (ITALIC.match?(lower) ? 2 : 0)
|
|
160
268
|
end
|
|
161
269
|
|
|
162
|
-
# Appearance text is already Windows-1252
|
|
163
|
-
# that measuring and rendering
|
|
270
|
+
# Appearance text is already Windows-1252, or raw font codes after
|
|
271
|
+
# #encode; anything else is converted so that measuring and rendering
|
|
272
|
+
# agree byte for byte.
|
|
164
273
|
def self.to_win_ansi(str)
|
|
165
|
-
return str if str.encoding
|
|
274
|
+
return str if BYTE_ENCODINGS.include?(str.encoding)
|
|
166
275
|
|
|
167
276
|
str.encode(WINDOWS_1252, invalid: :replace, undef: :replace, replace: '?')
|
|
168
277
|
end
|
data/lib/acrofill/version.rb
CHANGED
data/lib/acrofill.rb
CHANGED
|
@@ -5,6 +5,7 @@ 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'
|
|
9
10
|
require_relative 'acrofill/flattener'
|
|
10
11
|
require_relative 'acrofill/form'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: acrofill
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- stiig
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- lib/acrofill/document.rb
|
|
39
39
|
- lib/acrofill/filler.rb
|
|
40
40
|
- lib/acrofill/flattener.rb
|
|
41
|
+
- lib/acrofill/fonts.rb
|
|
41
42
|
- lib/acrofill/form.rb
|
|
42
43
|
- lib/acrofill/metrics.rb
|
|
43
44
|
- lib/acrofill/serializer.rb
|