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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8192b1e65d4be56876296608eecae9371866700b8a2016d6bbedad79062fca33
4
- data.tar.gz: 750c2b6ccd33d9e335933c6f5e5f906f0ca8adb0f5aa07e98a4eb6019104c0f9
3
+ metadata.gz: d7b9246e478c6ab2f8206ba44e766a2b4e9e9a946a48245d171d7e212bac54d2
4
+ data.tar.gz: e6f70298a5008e1419089328788cb1d66908cd3e2edfa2e3bd724e0a220ff176
5
5
  SHA512:
6
- metadata.gz: c081d29edbd239103797267d4f47cf2ed18ef3fa46026acae8ffc03b8d27e1b13e9477bba89c7646a554774059905bd833cd7d5df41717d95eb90ab72286d0f6
7
- data.tar.gz: a0b575761f075a4fc8f7690b85ec7addee099fcf5ef29ff5f1201e72ee47fd873398645c2dd51af07476073f3cb85f8cbc7570b46af158900372201c60841098
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.1.2...HEAD
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 nested arrays, and
151
- mistyped dictionaries are clamped or rejected rather than crashing the
152
- writer.
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, standard-14 width metrics
166
- (Helvetica/Courier/Times).
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.
@@ -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
- 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
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
- base_font = base_font_for(font_name)
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, base_font, size, width, height, align)
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, base_font, 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, 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 => font_ref(font_name) } }
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, base_font, size, width, align)
77
- text_width = Metrics.string_width(text, base_font, size)
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, base_font, size, width, height, align)
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), base_font, size, max_width)
101
+ wrap_line(printable_text(paragraph), font, size, max_width)
91
102
  end
92
103
 
93
- leading = size * 1.15
94
- first_y = height - PADDING - (size * ASCENT)
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, base_font, size, width, align)
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, base_font, size, max_width)
109
- space = Metrics.string_width(' ', base_font, size)
121
+ def wrap_line(text, font, size, max_width)
122
+ space = Metrics.string_width(' ', font.widths, size)
110
123
  lines = ['']
111
- widths = [0.0]
124
+ so_far = [0.0]
112
125
  text.split.each do |word|
113
- word_width = Metrics.string_width(word, base_font, size)
126
+ word_width = Metrics.string_width(word, font.widths, size)
114
127
  if lines.last.empty?
115
128
  lines[-1] = word
116
- widths[-1] = word_width
117
- elsif widths.last + space + word_width <= max_width
129
+ so_far[-1] = word_width
130
+ elsif so_far.last + space + word_width <= max_width
118
131
  lines[-1] = "#{lines.last} #{word}"
119
- widths[-1] += space + word_width
132
+ so_far[-1] += space + word_width
120
133
  else
121
134
  lines << word
122
- widths << word_width
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, base_font, size, width)
198
+ def shrink_to_fit(text, font, size, width)
186
199
  max_width = width - (2 * PADDING)
187
- text_width = Metrics.string_width(text, base_font, size)
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: '?')
@@ -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[:Type]
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.each { |group| fill_text(group, value) }.any?
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.each_page { |page| flatten_page(page) }
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
- # Annotation /F flags. Must be dereferenced before to_i: on a
80
- # PDF::Reader::Reference, to_i returns the object *number*.
81
- def annotation_flags(widget)
82
- flags = @doc.deref(widget[:F])
83
- flags.is_a?(Integer) ? flags : 0
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
- if value.empty?
95
- widget.delete(:AP)
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
- ap_ref = @appearance.build(node, widget, value, multiline: multiline)
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
@@ -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
- WIDTHS = {
8
- 'Helvetica' => [278, 278, 355, 556, 556, 889, 667, 222, 333, 333, 389, 584, 278, 333, 278, 278, 556,
9
- 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 222, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584],
10
- 'Helvetica-Bold' => [278, 333, 474, 556, 556, 889, 722, 278, 333, 333, 389, 584, 278, 333, 278, 278,
11
- 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 278, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584],
12
- 'Courier' => [600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
13
- 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600],
14
- 'Times-Roman' => [250, 333, 408, 500, 500, 833, 778, 333, 333, 333, 500, 564, 250, 333, 250, 278, 500,
15
- 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541]
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
- DEFAULT_WIDTH = 556
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
- def self.string_width(str, base_font, size)
21
- table = WIDTHS[base_font] || WIDTHS['Helvetica']
22
- units = str.each_char.sum do |ch|
23
- code = ch.ord
24
- code.between?(32, 126) ? table[code - 32] : DEFAULT_WIDTH
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acrofill
4
- VERSION = '0.1.2'
4
+ VERSION = '0.3.0'
5
5
  end
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.1.2
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