acrofill 0.1.2 → 0.2.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: c3fd7c0e555d7ae744ba74b2c4bde970f938db256eb22d364584d1923650fa8a
4
+ data.tar.gz: 2f1f9205e4562e4dd6b483fa2a13a3855cb167c6c3d9b18b92871b3af87a2589
5
5
  SHA512:
6
- metadata.gz: c081d29edbd239103797267d4f47cf2ed18ef3fa46026acae8ffc03b8d27e1b13e9477bba89c7646a554774059905bd833cd7d5df41717d95eb90ab72286d0f6
7
- data.tar.gz: a0b575761f075a4fc8f7690b85ec7addee099fcf5ef29ff5f1201e72ee47fd873398645c2dd51af07476073f3cb85f8cbc7570b46af158900372201c60841098
6
+ metadata.gz: c6abbdd9dd06f176b5d916fc8e6a5ab6345b827fb177eb4a8d1844dd6d0d544ab1dc1eca085f33fbd032c05e56bd84506acdc582c610c35ad9fe1335a168f0e1
7
+ data.tar.gz: fc8550ec29027ee7b8d8023ca441a2aeb39efbc213493852db77bd28a1729d95dfee2dc5b123c31479be74696783c357ba52f1c33a11eb09993bab93ab796e82
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-27
11
+
12
+ ### Fixed
13
+
14
+ - Pages whose `/Type` entry is missing are no longer skipped: flattening
15
+ such a document silently produced an unflattened file.
16
+ - A widget whose appearance cannot be regenerated (degenerate `/Rect`)
17
+ now has its `/AP` dropped instead of keeping the appearance of the
18
+ *previous* value while `/V` already holds the new one.
19
+ - A page `/Resources` or `/Resources /XObject` that is not a dictionary
20
+ no longer raises `TypeError` while flattening, and a `/Root` that is
21
+ missing or not a dictionary raises `Acrofill::Error` instead of
22
+ `NoMethodError`.
23
+ - Filling a field whose fully-qualified name is shared by several field
24
+ dictionaries no longer depends on the return value of the first fill.
25
+
26
+ ### Changed
27
+
28
+ - Text metrics now cover all standard-14 text cuts (Times bold/italic,
29
+ the Courier family, the oblique Helvetica cuts) over the full
30
+ WinAnsiEncoding range instead of ASCII-only Helvetica/Courier/
31
+ Times-Roman, so accented characters and bold or serif faces are
32
+ measured rather than approximated. Widths are WinAnsi-correct: `'`
33
+ measured 222 (StandardEncoding `quoteright`) where appearances
34
+ actually emit `quotesingle`.
35
+ - Unknown `/BaseFont` names (`ArialMT`, `TimesNewRomanPS-BoldMT`, subset
36
+ faces) are classified by family and weight instead of all falling back
37
+ to Helvetica.
38
+ - One fallback Helvetica font object is now shared by every generated
39
+ appearance; a 50-field form previously wrote 50 identical font
40
+ dictionaries.
41
+ - Flattening moved out of `Form` into its own `Acrofill::Flattener`.
42
+
10
43
  ## [0.1.2] - 2026-07-24
11
44
 
12
45
  ### Added
@@ -54,7 +87,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
87
  - `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
55
88
  `field_names`).
56
89
 
57
- [Unreleased]: https://github.com/stiig/acrofill/compare/v0.1.2...HEAD
90
+ [Unreleased]: https://github.com/stiig/acrofill/compare/v0.2.0...HEAD
91
+ [0.2.0]: https://github.com/stiig/acrofill/compare/v0.1.2...v0.2.0
58
92
  [0.1.2]: https://github.com/stiig/acrofill/compare/v0.1.1...v0.1.2
59
93
  [0.1.1]: https://github.com/stiig/acrofill/compare/v0.1.0...v0.1.1
60
94
  [0.1.0]: https://github.com/stiig/acrofill/releases/tag/v0.1.0
data/README.md CHANGED
@@ -147,9 +147,10 @@ corruption or code execution. Specifically it defends against:
147
147
  name and only well-formed colour operators (`g`/`rg`/`k`) are copied
148
148
  into generated appearances. Field **values** are always written as
149
149
  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.
150
+ - **Malformed scalars and mistyped structure** — non-finite reals, deeply
151
+ nested arrays, a `/Resources` that is not a dictionary, a `/Root` that
152
+ points nowhere: each is clamped, replaced or rejected rather than
153
+ raising a `TypeError`/`NoMethodError` out of the middle of a fill.
153
154
  - **Encrypted / unparseable input** — rejected up front; every failure
154
155
  at the parse boundary surfaces as `Acrofill::Error` (including lazy
155
156
  per-object parse errors and parser stack overflow on pathologically
@@ -162,8 +163,11 @@ Supported:
162
163
  - Text fields (`/Tx`) — hierarchical names (`parent.kid`), inherited
163
164
  `/DA`, alignment via `/Q` (left/center/right), auto font size (`0 Tf`),
164
165
  shrink-to-fit for overflowing values, multiline fields (`/Ff` bit 13)
165
- with word wrapping, standard-14 width metrics
166
- (Helvetica/Courier/Times).
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 subset — is classified by family and weight rather than all
170
+ measured as Helvetica.
167
171
  - Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
168
172
  using the template's own appearance states.
169
173
  - Choice fields (`/Ch`) — value set and rendered like text.
@@ -26,22 +26,22 @@ module Acrofill
26
26
  return nil if width <= 0 || height <= 0
27
27
 
28
28
  font_name, size, color_ops = parse_da(field_node)
29
- base_font = base_font_for(font_name)
29
+ widths = Metrics.widths_for(base_font_for(font_name))
30
30
  align = alignment(field_node)
31
31
 
32
32
  body =
33
33
  if multiline
34
34
  size = 12.0 if size <= 0
35
35
  size = size.clamp(2.0, 144.0)
36
- multiline_body(value, base_font, size, width, height, align)
36
+ multiline_body(value, widths, size, width, height, align)
37
37
  else
38
38
  text = printable_text(value)
39
39
  size = [height * 0.66, 12.0].min if size.zero?
40
- size = shrink_to_fit(text, base_font, size, width)
40
+ size = shrink_to_fit(text, widths, size, width)
41
41
  # Vertically center the ascent box, matching pdftk's baseline
42
42
  # placement exactly: ty = (h - ascent*size) / 2.
43
43
  ty = [(height - (size * ASCENT)) / 2.0, size * DESCENT].max
44
- "#{fmt(line_x(text, base_font, size, width, align))} #{fmt(ty)} Td\n" \
44
+ "#{fmt(line_x(text, widths, size, width, align))} #{fmt(ty)} Td\n" \
45
45
  "(#{escape_literal(text)}) Tj\n"
46
46
  end
47
47
 
@@ -73,8 +73,8 @@ module Acrofill
73
73
  align.is_a?(Integer) ? align : 0
74
74
  end
75
75
 
76
- def line_x(text, base_font, size, width, align)
77
- text_width = Metrics.string_width(text, base_font, size)
76
+ def line_x(text, widths, size, width, align)
77
+ text_width = Metrics.string_width(text, widths, size)
78
78
  case align
79
79
  when 1 then [(width - text_width) / 2.0, PADDING].max
80
80
  when 2 then [width - PADDING - text_width, PADDING].max
@@ -84,10 +84,10 @@ module Acrofill
84
84
 
85
85
  # Greedy word wrap, top-down, honouring explicit line breaks. Lines
86
86
  # that would fall below the box are clipped by the BBox.
87
- def multiline_body(value, base_font, size, width, height, align)
87
+ def multiline_body(value, widths, size, width, height, align)
88
88
  max_width = width - (2 * PADDING)
89
89
  lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
90
- wrap_line(printable_text(paragraph), base_font, size, max_width)
90
+ wrap_line(printable_text(paragraph), widths, size, max_width)
91
91
  end
92
92
 
93
93
  leading = size * 1.15
@@ -95,7 +95,7 @@ module Acrofill
95
95
  body = "#{fmt(leading)} TL\n"
96
96
  previous_x = 0.0
97
97
  lines.each_with_index do |line, index|
98
- x = line_x(line, base_font, size, width, align)
98
+ x = line_x(line, widths, size, width, align)
99
99
  body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
100
100
  body << "(#{escape_literal(line)}) Tj\nT*\n"
101
101
  previous_x = x
@@ -105,21 +105,21 @@ module Acrofill
105
105
 
106
106
  # Greedy wrap. Line and space widths are accumulated incrementally so
107
107
  # 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)
108
+ def wrap_line(text, widths, size, max_width)
109
+ space = Metrics.string_width(' ', widths, size)
110
110
  lines = ['']
111
- widths = [0.0]
111
+ so_far = [0.0]
112
112
  text.split.each do |word|
113
- word_width = Metrics.string_width(word, base_font, size)
113
+ word_width = Metrics.string_width(word, widths, size)
114
114
  if lines.last.empty?
115
115
  lines[-1] = word
116
- widths[-1] = word_width
117
- elsif widths.last + space + word_width <= max_width
116
+ so_far[-1] = word_width
117
+ elsif so_far.last + space + word_width <= max_width
118
118
  lines[-1] = "#{lines.last} #{word}"
119
- widths[-1] += space + word_width
119
+ so_far[-1] += space + word_width
120
120
  else
121
121
  lines << word
122
- widths << word_width
122
+ so_far << word_width
123
123
  end
124
124
  end
125
125
  lines
@@ -182,9 +182,9 @@ module Acrofill
182
182
 
183
183
  # Fixed sizes that overflow the box are scaled down so the whole value
184
184
  # stays visible (Acrobat-style best-fit; pdftk would clip instead).
185
- def shrink_to_fit(text, base_font, size, width)
185
+ def shrink_to_fit(text, widths, size, width)
186
186
  max_width = width - (2 * PADDING)
187
- text_width = Metrics.string_width(text, base_font, size)
187
+ text_width = Metrics.string_width(text, widths, size)
188
188
  size *= max_width / text_width if text_width > max_width && text_width.positive?
189
189
  size.clamp(2.0, 144.0)
190
190
  end
@@ -192,25 +192,30 @@ module Acrofill
192
192
  # The font resource dictionary from /AcroForm /DR /Font, or {} when the
193
193
  # template supplies a malformed (non-dictionary) /DR or /Font.
194
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 : {}
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
200
201
  end
201
202
 
202
203
  def base_font_for(resource_name)
203
204
  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}\+/, '')
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)
206
207
  end
207
208
 
208
209
  def font_ref(resource_name)
209
210
  entry = dr_fonts[resource_name.to_sym]
210
- return @doc.ref_for(entry) if entry
211
+ entry ? @doc.ref_for(entry) : fallback_font
212
+ end
211
213
 
212
- # Font not present in /DR: register a plain Helvetica.
213
- @doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
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)
214
219
  end
215
220
 
216
221
  def printable_text(value)
@@ -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
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,172 @@
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 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.
6
12
  module Metrics
13
+ DEFAULT_WIDTH = 556
14
+ COURIER_WIDTH = 600
15
+ FIRST_CODE = 32
16
+ LAST_CODE = 255
17
+ WINDOWS_1252 = Encoding::Windows_1252
18
+
19
+ # Bold/italic Times cuts, indexed by bold + 2 * italic.
20
+ TIMES = %w[Times-Roman Times-Bold Times-Italic Times-BoldItalic].freeze
21
+ # Family and weight hints for BaseFont names outside the standard 14.
22
+ SERIF = /times|roman|serif|georgia|garamond/
23
+ SANS = /sans/
24
+ FIXED = /courier|mono/
25
+ BOLD = /bold|black|heavy/
26
+ ITALIC = /italic|oblique/
27
+
7
28
  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]
29
+ 'Helvetica' => [
30
+ 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556,
31
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667,
32
+ 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722,
33
+ 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500,
34
+ 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278,
35
+ 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, nil, 556, nil, 222, 556, 333, 1000,
36
+ 556, 556, 333, 1000, 667, 333, 1000, nil, 611, nil, nil, 222, 222, 333, 333, 350, 556,
37
+ 1000, 333, 1000, 500, 333, 944, nil, 500, 667, 278, 333, 556, 556, 556, 556, 260, 556,
38
+ 333, 737, 370, 556, 584, 333, 737, 333, 400, 584, 333, 333, 333, 556, 537, 278, 333,
39
+ 333, 365, 556, 834, 834, 834, 611, 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667,
40
+ 667, 667, 278, 278, 278, 278, 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722,
41
+ 722, 722, 667, 667, 611, 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556,
42
+ 278, 278, 278, 278, 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556,
43
+ 500, 556, 500
44
+ ].freeze,
45
+ 'Helvetica-Bold' => [
46
+ 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556,
47
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722,
48
+ 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722,
49
+ 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556,
50
+ 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333,
51
+ 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, nil, 556, nil, 278, 556, 500, 1000,
52
+ 556, 556, 333, 1000, 667, 333, 1000, nil, 611, nil, nil, 278, 278, 500, 500, 350, 556,
53
+ 1000, 333, 1000, 556, 333, 944, nil, 500, 667, 278, 333, 556, 556, 556, 556, 280, 556,
54
+ 333, 737, 370, 556, 584, 333, 737, 333, 400, 584, 333, 333, 333, 611, 556, 278, 333,
55
+ 333, 365, 556, 834, 834, 834, 611, 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667,
56
+ 667, 667, 278, 278, 278, 278, 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722,
57
+ 722, 722, 667, 667, 611, 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556,
58
+ 278, 278, 278, 278, 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611,
59
+ 556, 611, 556
60
+ ].freeze,
61
+ 'Times-Roman' => [
62
+ 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, 500,
63
+ 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, 921, 722,
64
+ 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667,
65
+ 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, 333, 444, 500, 444,
66
+ 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, 500, 500, 333, 389, 278,
67
+ 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, nil, 500, nil, 333, 500, 444, 1000,
68
+ 500, 500, 333, 1000, 556, 333, 889, nil, 611, nil, nil, 333, 333, 444, 444, 350, 500,
69
+ 1000, 333, 980, 389, 333, 722, nil, 444, 722, 250, 333, 500, 500, 500, 500, 200, 500,
70
+ 333, 760, 276, 500, 564, 333, 760, 333, 400, 564, 300, 300, 333, 500, 453, 250, 333,
71
+ 300, 310, 500, 750, 750, 750, 444, 722, 722, 722, 722, 722, 722, 889, 667, 611, 611,
72
+ 611, 611, 333, 333, 333, 333, 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722,
73
+ 722, 722, 722, 556, 500, 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444,
74
+ 278, 278, 278, 278, 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500,
75
+ 500, 500, 500
76
+ ].freeze,
77
+ 'Times-Bold' => [
78
+ 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, 500,
79
+ 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, 930, 722,
80
+ 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, 611, 778, 722,
81
+ 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, 333, 500, 556, 444,
82
+ 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, 556, 556, 444, 389, 333,
83
+ 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, nil, 500, nil, 333, 500, 500, 1000,
84
+ 500, 500, 333, 1000, 556, 333, 1000, nil, 667, nil, nil, 333, 333, 500, 500, 350, 500,
85
+ 1000, 333, 1000, 389, 333, 722, nil, 444, 722, 250, 333, 500, 500, 500, 500, 220, 500,
86
+ 333, 747, 300, 500, 570, 333, 747, 333, 400, 570, 300, 300, 333, 556, 540, 250, 333,
87
+ 300, 330, 500, 750, 750, 750, 500, 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667,
88
+ 667, 667, 389, 389, 389, 389, 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722,
89
+ 722, 722, 722, 611, 556, 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444,
90
+ 278, 278, 278, 278, 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556,
91
+ 500, 556, 500
92
+ ].freeze,
93
+ 'Times-Italic' => [
94
+ 250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, 500,
95
+ 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, 920, 611,
96
+ 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, 611, 722, 611,
97
+ 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, 333, 500, 500, 444,
98
+ 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, 500, 500, 389, 389, 278,
99
+ 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, nil, 500, nil, 333, 500, 556, 889,
100
+ 500, 500, 333, 1000, 500, 333, 944, nil, 556, nil, nil, 333, 333, 556, 556, 350, 500,
101
+ 889, 333, 980, 389, 333, 667, nil, 389, 556, 250, 389, 500, 500, 500, 500, 275, 500,
102
+ 333, 760, 276, 500, 675, 333, 760, 333, 400, 675, 300, 300, 333, 500, 523, 250, 333,
103
+ 300, 310, 500, 750, 750, 750, 500, 611, 611, 611, 611, 611, 611, 889, 667, 611, 611,
104
+ 611, 611, 333, 333, 333, 333, 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722,
105
+ 722, 722, 556, 611, 500, 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444,
106
+ 278, 278, 278, 278, 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500,
107
+ 444, 500, 444
108
+ ].freeze,
109
+ 'Times-BoldItalic' => [
110
+ 250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, 500,
111
+ 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, 832, 667,
112
+ 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, 611, 722, 667,
113
+ 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, 333, 500, 500, 444,
114
+ 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, 500, 500, 389, 389, 278,
115
+ 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, nil, 500, nil, 333, 500, 500, 1000,
116
+ 500, 500, 333, 1000, 556, 333, 944, nil, 611, nil, nil, 333, 333, 500, 500, 350, 500,
117
+ 1000, 333, 1000, 389, 333, 722, nil, 389, 611, 250, 389, 500, 500, 500, 500, 220, 500,
118
+ 333, 747, 266, 500, 606, 333, 747, 333, 400, 570, 300, 300, 333, 576, 500, 250, 333,
119
+ 300, 300, 500, 750, 750, 750, 500, 667, 667, 667, 667, 667, 667, 944, 667, 667, 667,
120
+ 667, 667, 389, 389, 389, 389, 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722,
121
+ 722, 722, 611, 611, 500, 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444,
122
+ 278, 278, 278, 278, 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556,
123
+ 444, 500, 444
124
+ ].freeze,
125
+ 'Courier' => Array.new(LAST_CODE - FIRST_CODE + 1, COURIER_WIDTH).freeze
16
126
  }.freeze
17
127
 
18
- DEFAULT_WIDTH = 556
19
-
20
- def self.string_width(str, base_font, size)
21
- table = WIDTHS[base_font] || WIDTHS['Helvetica']
22
- units = str.each_char.sum do |ch|
23
- code = ch.ord
24
- code.between?(32, 126) ? table[code - 32] : DEFAULT_WIDTH
128
+ # Width of +str+ at +size+ points. +widths+ is a table from #widths_for,
129
+ # or a BaseFont name (resolved here, at the cost of the lookup).
130
+ def self.string_width(str, widths, size)
131
+ widths = widths_for(widths) unless widths.is_a?(Array)
132
+ str = to_win_ansi(str)
133
+ units = str.each_byte.sum do |code|
134
+ (code >= FIRST_CODE && widths[code - FIRST_CODE]) || DEFAULT_WIDTH
25
135
  end
26
136
  units * size / 1000.0
27
137
  end
138
+
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.
143
+ def self.widths_for(base_font)
144
+ name = base_font.to_s
145
+ return WIDTHS[name] if WIDTHS.key?(name)
146
+
147
+ 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']
152
+ end
153
+
154
+ def self.serif?(lower)
155
+ SERIF.match?(lower) && !SANS.match?(lower)
156
+ end
157
+
158
+ def self.weight_index(lower)
159
+ (BOLD.match?(lower) ? 1 : 0) + (ITALIC.match?(lower) ? 2 : 0)
160
+ end
161
+
162
+ # Appearance text is already Windows-1252; anything else is converted so
163
+ # that measuring and rendering agree byte for byte.
164
+ def self.to_win_ansi(str)
165
+ return str if str.encoding == WINDOWS_1252
166
+
167
+ str.encode(WINDOWS_1252, invalid: :replace, undef: :replace, replace: '?')
168
+ end
169
+
170
+ private_class_method :serif?, :weight_index, :to_win_ansi
28
171
  end
29
172
  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.2.0'
5
5
  end
data/lib/acrofill.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'acrofill/metrics'
6
6
  require_relative 'acrofill/serializer'
7
7
  require_relative 'acrofill/writer'
8
8
  require_relative 'acrofill/appearance'
9
+ require_relative 'acrofill/flattener'
9
10
  require_relative 'acrofill/form'
10
11
  require_relative 'acrofill/filler'
11
12
  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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - stiig
@@ -37,6 +37,7 @@ files:
37
37
  - lib/acrofill/appearance.rb
38
38
  - lib/acrofill/document.rb
39
39
  - lib/acrofill/filler.rb
40
+ - lib/acrofill/flattener.rb
40
41
  - lib/acrofill/form.rb
41
42
  - lib/acrofill/metrics.rb
42
43
  - lib/acrofill/serializer.rb