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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3fd7c0e555d7ae744ba74b2c4bde970f938db256eb22d364584d1923650fa8a
4
- data.tar.gz: 2f1f9205e4562e4dd6b483fa2a13a3855cb167c6c3d9b18b92871b3af87a2589
3
+ metadata.gz: 633b173570db39e5b16714fd23e558a6832ed36f38a549802eb9dcda64967854
4
+ data.tar.gz: 26d94b282728f297c7c61d51ed817749d8435ecdd6dd4962a11942ac0af30fe9
5
5
  SHA512:
6
- metadata.gz: c6abbdd9dd06f176b5d916fc8e6a5ab6345b827fb177eb4a8d1844dd6d0d544ab1dc1eca085f33fbd032c05e56bd84506acdc582c610c35ad9fe1335a168f0e1
7
- data.tar.gz: fc8550ec29027ee7b8d8023ca441a2aeb39efbc213493852db77bd28a1729d95dfee2dc5b123c31479be74696783c357ba52f1c33a11eb09993bab93ab796e82
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.2.0...HEAD
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 WinAnsi width metrics for all standard-14 text
167
- cuts (Helvetica/Times/Courier, regular through bold-italic). A
168
- template's own `/BaseFont` `ArialMT`, `TimesNewRomanPS-BoldMT`, an
169
- embedded subsetis classified by family and weight rather than all
170
- measured as Helvetica.
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; comb fields render as plain text; rich text (`/RV`) is
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
 
@@ -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
- ASCENT = 0.718 # Helvetica cap-height-ish ascent, em fractions
9
- DESCENT = 0.207
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
- width = rect[2] - rect[0]
25
- height = rect[3] - rect[1]
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
- widths = Metrics.widths_for(base_font_for(font_name))
30
- align = alignment(field_node)
31
-
32
- body =
33
- if multiline
34
- size = 12.0 if size <= 0
35
- size = size.clamp(2.0, 144.0)
36
- multiline_body(value, widths, size, width, height, align)
37
- else
38
- text = printable_text(value)
39
- size = [height * 0.66, 12.0].min if size.zero?
40
- size = shrink_to_fit(text, widths, size, width)
41
- # Vertically center the ascent box, matching pdftk's baseline
42
- # placement exactly: ty = (h - ascent*size) / 2.
43
- ty = [(height - (size * ASCENT)) / 2.0, size * DESCENT].max
44
- "#{fmt(line_x(text, widths, size, width, align))} #{fmt(ty)} Td\n" \
45
- "(#{escape_literal(text)}) Tj\n"
46
- end
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
- content = +"/Tx BMC\nq\nBT\n"
49
- content << "#{color_ops}\n" unless color_ops.empty?
50
- content << "/#{font_name} #{fmt(size)} Tf\n"
51
- content << body
52
- content << "ET\nQ\nEMC\n"
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
- dict = {
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 => font_ref(font_name) } }
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
- private
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
- def fmt(num)
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, widths, size, width, align)
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, widths, size, width, height, align)
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), widths, size, max_width)
136
+ wrap_line(font.encode(printable_text(paragraph)), font, size, max_width)
91
137
  end
92
138
 
93
- leading = size * 1.15
94
- first_y = height - PADDING - (size * ASCENT)
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, widths, size, width, align)
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, widths, size, max_width)
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, widths, size, width)
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: '?')
@@ -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
- return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
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
- multiline = field_flags(node).anybits?(MULTILINE_FLAG)
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 = @appearance.build(node, widget, value, multiline: multiline) unless value.empty?
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
@@ -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 same
10
- # widths as the upright face, and the whole Courier family is monospaced.
11
- # #widths_for maps every other BaseFont name onto them.
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
- # Bold/italic Times cuts, indexed by bold + 2 * italic.
20
- TIMES = %w[Times-Roman Times-Bold Times-Italic Times-BoldItalic].freeze
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
- WIDTHS = {
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
- # The width table for a BaseFont name. Standard-14 names hit the table
140
- # directly; anything else (ArialMT, TimesNewRomanPS-BoldMT, a template's
141
- # embedded face) is classified by family and weight, which is far closer
142
- # than measuring everything as Helvetica.
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 WIDTHS[name] if WIDTHS.key?(name)
252
+ return name if FONTS.key?(name)
146
253
 
147
254
  lower = name.downcase
148
- return WIDTHS['Courier'] if FIXED.match?(lower)
149
- return WIDTHS[TIMES[weight_index(lower)]] if serif?(lower)
150
-
151
- WIDTHS[BOLD.match?(lower) ? 'Helvetica-Bold' : 'Helvetica']
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; anything else is converted so
163
- # that measuring and rendering agree byte for byte.
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 == WINDOWS_1252
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acrofill
4
- VERSION = '0.2.0'
4
+ VERSION = '0.4.0'
5
5
  end
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.2.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