acrofill 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7b9246e478c6ab2f8206ba44e766a2b4e9e9a946a48245d171d7e212bac54d2
4
- data.tar.gz: e6f70298a5008e1419089328788cb1d66908cd3e2edfa2e3bd724e0a220ff176
3
+ metadata.gz: 633b173570db39e5b16714fd23e558a6832ed36f38a549802eb9dcda64967854
4
+ data.tar.gz: 26d94b282728f297c7c61d51ed817749d8435ecdd6dd4962a11942ac0af30fe9
5
5
  SHA512:
6
- metadata.gz: 4f858c6d8e34cf948d8ef554d31d9e0a7187408f256fadff63c15bd4af3ad6ad6e61329770e9db506482f179635b9305393d432a9176742893aa45643810f22c
7
- data.tar.gz: 75b8174ebb610ec9be61a7b214a69a47071d5aa797bb13378917eb3d41b4279c9ee81e1348641703dd29e5436ca1dcda30148b3e50c70759b2d47fad8c137cb1
6
+ metadata.gz: c5aad0c14c51e0d9e28a2c4c09b03d840b01c91ef83908ea0d31674f12ff0e4da8c33e7918600ba8ef409313a4faf0e8cfad30b325b3e23fa5c2786643fcdc97
7
+ data.tar.gz: 18be3697b53f7fe00d6d815a8cd7e0755ca3eef63eccc02e4f9dba181c4a14df648b05289e10ba9bf92b4e0ff62032ff9b1f42a6464a4ed3b75cd2c09b693261
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-07-27
11
+
12
+ ### Added
13
+
14
+ - Comb fields (`/Ff` bit 25 with `/MaxLen`) lay each character out centered
15
+ in its own cell, `/Q` choosing which run of cells the value occupies,
16
+ matching pdftk cell for cell. They previously rendered as plain text.
17
+
18
+ ### Fixed
19
+
20
+ - A value is now written in the codes its font actually draws it with. A
21
+ `/DR` font whose `/Encoding` carries a `/Differences` array moves glyphs
22
+ to other codes; writing the raw bytes drew whatever glyph happened to sit
23
+ there — a font mapping "A" to code 90 rendered "AZ" as "ZA". Widths
24
+ follow the emitted code, so measurement and rendering stay in step.
25
+
26
+ ### Changed
27
+
28
+ - Flattening no longer stamps widgets a viewer would not display: `/F`
29
+ Hidden (bit 2) was already dropped, and NoView (bit 6, "not on screen",
30
+ PDF 32000 §12.5.3) now is too. pdftk stamps both, but burning in a
31
+ widget the template author concealed makes hidden values permanently
32
+ visible, so parity loses here. Widgets that are merely non-printing are
33
+ still stamped: they are what the viewer shows.
34
+
10
35
  ## [0.3.0] - 2026-07-27
11
36
 
12
37
  ### Fixed
@@ -142,7 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
142
167
  - `PdfForms`-compatible entry points (`Acrofill.new`, `fill_form`, `fields`,
143
168
  `field_names`).
144
169
 
145
- [Unreleased]: https://github.com/stiig/acrofill/compare/v0.3.0...HEAD
170
+ [Unreleased]: https://github.com/stiig/acrofill/compare/v0.4.0...HEAD
171
+ [0.4.0]: https://github.com/stiig/acrofill/compare/v0.3.0...v0.4.0
146
172
  [0.3.0]: https://github.com/stiig/acrofill/compare/v0.2.0...v0.3.0
147
173
  [0.2.0]: https://github.com/stiig/acrofill/compare/v0.1.2...v0.2.0
148
174
  [0.1.2]: https://github.com/stiig/acrofill/compare/v0.1.1...v0.1.2
data/README.md CHANGED
@@ -91,11 +91,16 @@ your own templates with:
91
91
  ruby benchmark/geometry_diff.rb path/to/form.pdf
92
92
  ```
93
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.
94
+ Flattened output was checked the same way: on those four forms every
95
+ stamped fragment lands where pdftk stamps it, bar the fields covered by the
96
+ deliberate differences below.
97
+
98
+ Four differences are deliberate: acrofill shrinks an overlong value to fit
99
+ where pdftk clips it; it writes Windows-1252 for non-ASCII values where
100
+ pdftk emits UTF-8 bytes into a WinAnsi font (which renders as mojibake); it
101
+ caps auto-sized (`0 Tf`) text at 12pt where pdftk scales it to fill the box;
102
+ and it drops widgets flagged Hidden or NoView when flattening instead of
103
+ burning them into the page. See the changelog for the measured numbers.
99
104
 
100
105
  ## Performance
101
106
 
@@ -194,6 +199,7 @@ Supported:
194
199
  rather than all measured as Helvetica.
195
200
  - Checkboxes and radio groups (`/Btn`) — state selection via `/V`+`/AS`
196
201
  using the template's own appearance states.
202
+ - Comb fields (`/Ff` bit 25) — one character centered per `/MaxLen` cell.
197
203
  - Choice fields (`/Ch`) — value set and rendered like text.
198
204
  - Flattening — every visible widget appearance is stamped into the page
199
205
  content; widget annotations and the AcroForm dictionary are removed.
@@ -204,8 +210,7 @@ Not supported (rejected or ignored, never a hard crash):
204
210
 
205
211
  - Encrypted documents (raise `Acrofill::Error`), XFA forms, digital
206
212
  signatures, JavaScript actions.
207
- - Push buttons; comb fields render as plain text; rich text (`/RV`) is
208
- dropped on fill.
213
+ - Push buttons; rich text (`/RV`) is dropped on fill.
209
214
  - Glyphs outside Windows-1252 in generated appearances (stored values
210
215
  keep full Unicode; unrenderable glyphs appear as `?`).
211
216
 
@@ -7,6 +7,9 @@ module Acrofill
7
7
  PADDING = 2.0
8
8
  # pdftk offsets the first multiline row by 1pt from the box top.
9
9
  TOP_OFFSET = 1.0
10
+
11
+ # The drawable area of one widget: its size and the field's /Q.
12
+ Box = Struct.new(:width, :height, :align)
10
13
  # Colour-setting operators allowed in a /DA string, and their operand counts.
11
14
  COLOR_OP_ARITY = { 'g' => 1, 'rg' => 3, 'k' => 4 }.freeze
12
15
 
@@ -18,51 +21,65 @@ module Acrofill
18
21
 
19
22
  # Returns a Reference to the new appearance XObject, or nil when the
20
23
  # widget geometry is unusable.
21
- def build(field_node, widget, value, multiline: false)
24
+ def build(field_node, widget, value, multiline: false, comb: nil)
22
25
  rect = normalized_rect(widget[:Rect] || @doc.inherited_value(field_node, :Rect))
23
26
  return nil unless rect
24
27
 
25
- width = rect[2] - rect[0]
26
- height = rect[3] - rect[1]
27
- return nil if width <= 0 || height <= 0
28
+ box = Box.new(rect[2] - rect[0], rect[3] - rect[1], alignment(field_node))
29
+ return nil if box.width <= 0 || box.height <= 0
28
30
 
29
31
  font_name, size, color_ops = parse_da(field_node)
30
32
  font = @fonts.metrics(font_name)
31
- align = alignment(field_node)
33
+ size, body = draw(value, font, size, box, multiline: multiline, comb: comb)
32
34
 
33
- body =
34
- if multiline
35
- size = 12.0 if size <= 0
36
- size = size.clamp(2.0, 144.0)
37
- multiline_body(value, font, size, width, height, align)
38
- else
39
- text = printable_text(value)
40
- size = [height * 0.66, 12.0].min if size.zero?
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" \
43
- "(#{escape_literal(text)}) Tj\n"
44
- end
35
+ @doc.add(StreamObject.new(appearance_dict(font_name, box),
36
+ content(font_name, size, color_ops, body).b))
37
+ end
45
38
 
46
- content = +"/Tx BMC\nq\nBT\n"
47
- content << "#{color_ops}\n" unless color_ops.empty?
48
- content << "/#{font_name} #{fmt(size)} Tf\n"
49
- content << body
50
- content << "ET\nQ\nEMC\n"
39
+ private
51
40
 
52
- dict = {
41
+ def fmt(num)
42
+ Serializer.format_number(num.to_f)
43
+ end
44
+
45
+ # The operators drawing +value+, and the point size they were laid out
46
+ # at — auto-sizing and shrink-to-fit both adjust the /DA size.
47
+ def draw(value, font, size, box, multiline:, comb:)
48
+ if multiline
49
+ size = (size <= 0 ? 12.0 : size).clamp(2.0, 144.0)
50
+ [size, multiline_body(value, font, size, box)]
51
+ elsif comb
52
+ size = auto_size(size, box.height).clamp(2.0, 144.0)
53
+ [size, comb_body(font.encode(printable_text(value)), font, size, box, comb)]
54
+ else
55
+ text = font.encode(printable_text(value))
56
+ size = shrink_to_fit(text, font, auto_size(size, box.height), box.width)
57
+ [size, "#{fmt(line_x(text, font, size, box))} #{fmt(baseline(box.height, font, size))} Td\n" \
58
+ "(#{escape_literal(text)}) Tj\n"]
59
+ end
60
+ end
61
+
62
+ # A /DA size of 0 means "fit the box"; acrofill caps that at 12pt.
63
+ def auto_size(size, height)
64
+ size.zero? ? [height * 0.66, 12.0].min : size
65
+ end
66
+
67
+ def content(font_name, size, color_ops, body)
68
+ stream = +"/Tx BMC\nq\nBT\n"
69
+ stream << "#{color_ops}\n" unless color_ops.empty?
70
+ stream << "/#{font_name} #{fmt(size)} Tf\n"
71
+ stream << body
72
+ stream << "ET\nQ\nEMC\n"
73
+ end
74
+
75
+ def appearance_dict(font_name, box)
76
+ {
53
77
  Type: :XObject,
54
78
  Subtype: :Form,
55
79
  FormType: 1,
56
- BBox: [0, 0, width, height],
80
+ BBox: [0, 0, box.width, box.height],
57
81
  Resources: { Font: { font_name.to_sym => @fonts.reference(font_name) } }
58
82
  }
59
- @doc.add(StreamObject.new(dict, content.b))
60
- end
61
-
62
- private
63
-
64
- def fmt(num)
65
- Serializer.format_number(num.to_f)
66
83
  end
67
84
 
68
85
  # Where pdftk puts a single line's baseline: the ascent box centered in
@@ -84,31 +101,49 @@ module Acrofill
84
101
  align.is_a?(Integer) ? align : 0
85
102
  end
86
103
 
87
- def line_x(text, font, size, width, align)
104
+ def line_x(text, font, size, box)
88
105
  text_width = Metrics.string_width(text, font.widths, size)
89
- case align
90
- when 1 then [(width - text_width) / 2.0, PADDING].max
91
- when 2 then [width - PADDING - text_width, PADDING].max
106
+ case box.align
107
+ when 1 then [(box.width - text_width) / 2.0, PADDING].max
108
+ when 2 then [box.width - PADDING - text_width, PADDING].max
92
109
  else PADDING
93
110
  end
94
111
  end
95
112
 
113
+ # A comb field divides its box into /MaxLen equal cells and centers one
114
+ # character in each; /Q picks the run of cells the value occupies. Each
115
+ # glyph is positioned absolutely, the way pdftk writes it.
116
+ def comb_body(text, font, size, box, cells)
117
+ cell = box.width / cells.to_f
118
+ chars = text.chars
119
+ first = case box.align
120
+ when 1 then [(cells - chars.size) / 2, 0].max
121
+ when 2 then [cells - chars.size, 0].max
122
+ else 0
123
+ end
124
+ ty = fmt(baseline(box.height, font, size))
125
+ chars.each_with_index.map do |char, index|
126
+ x = ((first + index + 0.5) * cell) - (Metrics.string_width(char, font.widths, size) / 2.0)
127
+ "1 0 0 1 #{fmt(x)} #{ty} Tm\n(#{escape_literal(char)}) Tj\n"
128
+ end.join
129
+ end
130
+
96
131
  # Greedy word wrap, top-down, honouring explicit line breaks. Lines
97
132
  # that would fall below the box are clipped by the BBox.
98
- def multiline_body(value, font, size, width, height, align)
99
- max_width = width - (2 * PADDING)
133
+ def multiline_body(value, font, size, box)
134
+ max_width = box.width - (2 * PADDING)
100
135
  lines = value.to_s.split(/\r\n|[\r\n]/).flat_map do |paragraph|
101
- wrap_line(printable_text(paragraph), font, size, max_width)
136
+ wrap_line(font.encode(printable_text(paragraph)), font, size, max_width)
102
137
  end
103
138
 
104
139
  # pdftk spaces rows by the font's FontBBox extent and drops the first
105
140
  # baseline by that extent from the top of the box.
106
141
  leading = font.line_height(size)
107
- first_y = height - font.top(size) + TOP_OFFSET
142
+ first_y = box.height - font.top(size) + TOP_OFFSET
108
143
  body = "#{fmt(leading)} TL\n"
109
144
  previous_x = 0.0
110
145
  lines.each_with_index do |line, index|
111
- x = line_x(line, font, size, width, align)
146
+ x = line_x(line, font, size, box)
112
147
  body << "#{fmt(x - previous_x)} #{index.zero? ? fmt(first_y) : '0'} Td\n"
113
148
  body << "(#{escape_literal(line)}) Tj\nT*\n"
114
149
  previous_x = x
@@ -6,6 +6,8 @@ module Acrofill
6
6
  # comes from the template, so each one is validated before use.
7
7
  class Flattener
8
8
  HIDDEN_FLAG = 2
9
+ NOVIEW_FLAG = 32
10
+ INVISIBLE = HIDDEN_FLAG | NOVIEW_FLAG
9
11
 
10
12
  def initialize(doc)
11
13
  @doc = doc
@@ -61,7 +63,12 @@ module Acrofill
61
63
  # form's /Matrix is applied to its BBox, and the resulting extent is
62
64
  # mapped onto the annotation rectangle.
63
65
  def stamp_operations(page, widget)
64
- return nil if annotation_flags(widget).anybits?(HIDDEN_FLAG)
66
+ # Flattening produces the document as it is displayed, so a widget the
67
+ # viewer would not show is dropped rather than burned in: /F Hidden,
68
+ # and NoView, which means "not on screen" (PDF 32000 §12.5.3). pdftk
69
+ # stamps both, but stamping Hidden makes values the template author
70
+ # concealed permanently visible, so parity loses here.
71
+ return nil if annotation_flags(widget).anybits?(INVISIBLE)
65
72
 
66
73
  ap_ref = normal_appearance(widget)
67
74
  xobject = @doc.deref(ap_ref)
@@ -15,6 +15,7 @@ module Acrofill
15
15
  # widths /Widths + /FirstChar, else the standard-14 table
16
16
  # ascender /FontDescriptor /Ascent, else standard-14 AFM, else 800
17
17
  # FontBBox /FontDescriptor /FontBBox, else standard-14 AFM, else 900/-200
18
+ # codes /Encoding /Differences remap the value's bytes
18
19
  #
19
20
  # The standard-14 fallbacks apply only to a BaseFont that literally names
20
21
  # one of the fourteen; a face merely *resembling* one (ArialMT and friends)
@@ -29,6 +30,7 @@ module Acrofill
29
30
  # not consult /MissingWidth (verified — setting it changes nothing).
30
31
  OUT_OF_RANGE_WIDTH = 0
31
32
  SUBSET_PREFIX = /\A[A-Z]{6}\+/
33
+ IDENTITY = Array.new(256) { |code| code }.freeze
32
34
 
33
35
  def initialize(doc, acroform)
34
36
  @doc = doc
@@ -82,8 +84,56 @@ module Acrofill
82
84
  name = base_font_name(dict)
83
85
  standard = Metrics.standard_font(name)
84
86
  ascender, descender, top, bottom = vertical(dict, standard)
85
- Metrics::Font.new(widths(dict) || Metrics.widths_for(name), ascender, descender, top, bottom)
86
- .freeze
87
+ Metrics::Font.new(widths(dict) || Metrics.widths_for(name),
88
+ ascender, descender, top, bottom, remap(dict)).freeze
89
+ end
90
+
91
+ # A 256-entry code translation table when /Encoding /Differences moves
92
+ # glyphs to codes other than their WinAnsi ones, else nil. Without it a
93
+ # value is drawn with whatever glyphs happen to sit at its bytes: a font
94
+ # that puts "A" at code 90 would render "AZ" as "ZA".
95
+ def remap(dict)
96
+ differences = differences(dict)
97
+ return nil if differences.empty?
98
+
99
+ by_code = {}
100
+ Metrics::WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
101
+ by_code[index + Metrics::FIRST_CODE] = glyph if glyph
102
+ end
103
+ differences.each { |code, glyph| by_code[code] = glyph }
104
+
105
+ at = {}
106
+ by_code.each { |code, glyph| at[glyph] ||= code }
107
+ table = Array.new(256) { |code| code }
108
+ Metrics::WIN_ANSI_GLYPHS.each_with_index do |glyph, index|
109
+ code = glyph && at[glyph]
110
+ table[index + Metrics::FIRST_CODE] = code if code
111
+ end
112
+ table == IDENTITY ? nil : table.freeze
113
+ end
114
+
115
+ # /Differences is a flat array where an integer restarts the code
116
+ # counter and each following name takes the next code (PDF 32000
117
+ # §9.6.6.1).
118
+ def differences(dict)
119
+ encoding = @doc.deref(dict[:Encoding])
120
+ return {} unless encoding.is_a?(Hash)
121
+
122
+ entries = @doc.deref(encoding[:Differences])
123
+ return {} unless entries.is_a?(Array)
124
+
125
+ code = nil
126
+ entries.each_with_object({}) do |raw, result|
127
+ item = @doc.deref(raw)
128
+ case item
129
+ when Integer then code = item
130
+ when Symbol
131
+ next unless code&.between?(0, 255)
132
+
133
+ result[code] = item # a glyph name, matching Metrics::WIN_ANSI_GLYPHS
134
+ code += 1
135
+ end
136
+ end
87
137
  end
88
138
 
89
139
  def base_font_name(dict)
data/lib/acrofill/form.rb CHANGED
@@ -6,6 +6,7 @@ module Acrofill
6
6
  class Form
7
7
  MULTILINE_FLAG = 1 << 12
8
8
  PUSHBUTTON_FLAG = 1 << 16
9
+ COMB_FLAG = 1 << 24
9
10
 
10
11
  Field = Struct.new(:name, :type, :value, :states, keyword_init: true)
11
12
 
@@ -81,15 +82,23 @@ module Acrofill
81
82
  true
82
83
  end
83
84
 
85
+ def build_appearance(node, widget, value, multiline, comb)
86
+ return nil if value.empty?
87
+
88
+ @appearance.build(node, widget, value, multiline: multiline, comb: comb)
89
+ end
90
+
84
91
  def fill_text(group, value)
85
92
  node = group[:node]
86
93
  node[:V] = pdf_text_string(value)
87
94
  node.delete(:RV)
88
95
  node.delete(:I)
89
- multiline = field_flags(node).anybits?(MULTILINE_FLAG)
96
+ flags = field_flags(node)
97
+ multiline = flags.anybits?(MULTILINE_FLAG)
98
+ comb = comb_cells(node, flags, multiline)
90
99
  group[:widgets].each do |widget|
91
100
  widget.delete(:AS)
92
- ap_ref = @appearance.build(node, widget, value, multiline: multiline) unless value.empty?
101
+ ap_ref = build_appearance(node, widget, value, multiline, comb)
93
102
  # An unusable geometry yields no appearance; dropping /AP is still
94
103
  # required, or the widget would keep rendering the *previous* value
95
104
  # while /V already holds the new one.
@@ -102,6 +111,16 @@ module Acrofill
102
111
  true
103
112
  end
104
113
 
114
+ # How many cells a comb field lays its value out in, or nil when the
115
+ # field is not a comb. The flag only means anything alongside /MaxLen
116
+ # and on a single-line field (PDF 32000 §12.7.4.3).
117
+ def comb_cells(node, flags, multiline)
118
+ return nil if multiline || !flags.anybits?(COMB_FLAG)
119
+
120
+ cells = @doc.deref(@doc.inherited_value(node, :MaxLen))
121
+ cells.is_a?(Integer) && cells.positive? ? cells : nil
122
+ end
123
+
105
124
  # Checkboxes and radio groups carry per-state appearance streams, so
106
125
  # filling only selects a state: /V on the field, /AS on each widget.
107
126
  # The value may be a state name ("Yes"), or anything non-empty when the
@@ -16,6 +16,8 @@ module Acrofill
16
16
  FIRST_CODE = 32
17
17
  LAST_CODE = 255
18
18
  WINDOWS_1252 = Encoding::Windows_1252
19
+ # Encodings whose bytes are already the codes the font is drawn with.
20
+ BYTE_ENCODINGS = [Encoding::Windows_1252, Encoding::BINARY].freeze
19
21
 
20
22
  # The standard-14 text cuts, indexed by bold + 2 * italic per family.
21
23
  CUTS = {
@@ -158,8 +160,48 @@ module Acrofill
158
160
  'Courier-BoldOblique' => [629, -157, 801, -250].freeze
159
161
  }.freeze
160
162
 
161
- # Everything the appearance code needs about one resolved face.
162
- Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom) do
163
+ # WinAnsiEncoding glyph names, index = code - 32. Used to re-encode a
164
+ # value for a font whose /Encoding remaps codes: the value is turned
165
+ # into glyph names, then into the codes *this* font draws them at.
166
+ WIN_ANSI_GLYPHS = [
167
+ :space, :exclam, :quotedbl, :numbersign, :dollar, :percent, :ampersand, :quotesingle,
168
+ :parenleft, :parenright, :asterisk, :plus, :comma, :hyphen, :period, :slash, :zero,
169
+ :one, :two, :three, :four, :five, :six, :seven, :eight, :nine, :colon, :semicolon,
170
+ :less, :equal, :greater, :question, :at, :A, :B, :C, :D, :E, :F, :G, :H, :I, :J, :K,
171
+ :L, :M, :N, :O, :P, :Q, :R, :S, :T, :U, :V, :W, :X, :Y, :Z, :bracketleft, :backslash,
172
+ :bracketright, :asciicircum, :underscore, :grave, :a, :b, :c, :d, :e, :f, :g, :h, :i,
173
+ :j, :k, :l, :m, :n, :o, :p, :q, :r, :s, :t, :u, :v, :w, :x, :y, :z, :braceleft, :bar,
174
+ :braceright, :asciitilde, :controlDEL, :Euro, nil, :quotesinglbase, :florin,
175
+ :quotedblbase, :ellipsis, :dagger, :daggerdbl, :circumflex, :perthousand, :Scaron,
176
+ :guilsinglleft, :OE, nil, :Zcaron, nil, nil, :quoteleft, :quoteright, :quotedblleft,
177
+ :quotedblright, :bullet, :endash, :emdash, :tilde, :trademark, :scaron,
178
+ :guilsinglright, :oe, nil, :zcaron, :Ydieresis, :space, :exclamdown, :cent, :sterling,
179
+ :currency, :yen, :brokenbar, :section, :dieresis, :copyright, :ordfeminine,
180
+ :guillemotleft, :logicalnot, :hyphen, :registered, :macron, :degree, :plusminus,
181
+ :twosuperior, :threesuperior, :acute, :mu, :paragraph, :periodcentered, :cedilla,
182
+ :onesuperior, :ordmasculine, :guillemotright, :onequarter, :onehalf, :threequarters,
183
+ :questiondown, :Agrave, :Aacute, :Acircumflex, :Atilde, :Adieresis, :Aring, :AE,
184
+ :Ccedilla, :Egrave, :Eacute, :Ecircumflex, :Edieresis, :Igrave, :Iacute, :Icircumflex,
185
+ :Idieresis, :Eth, :Ntilde, :Ograve, :Oacute, :Ocircumflex, :Otilde, :Odieresis,
186
+ :multiply, :Oslash, :Ugrave, :Uacute, :Ucircumflex, :Udieresis, :Yacute, :Thorn,
187
+ :germandbls, :agrave, :aacute, :acircumflex, :atilde, :adieresis, :aring, :ae,
188
+ :ccedilla, :egrave, :eacute, :ecircumflex, :edieresis, :igrave, :iacute, :icircumflex,
189
+ :idieresis, :eth, :ntilde, :ograve, :oacute, :ocircumflex, :otilde, :odieresis,
190
+ :divide, :oslash, :ugrave, :uacute, :ucircumflex, :udieresis, :yacute, :thorn,
191
+ :ydieresis
192
+ ].freeze
193
+
194
+ # Everything the appearance code needs about one resolved face. +remap+
195
+ # is a 256-entry code translation table, or nil when the font draws
196
+ # WinAnsi codes as they are.
197
+ Font = Struct.new(:widths, :ascender, :descender, :bbox_top, :bbox_bottom, :remap) do
198
+ # Windows-1252 text as the byte codes this font draws it with.
199
+ def encode(text)
200
+ return text unless remap
201
+
202
+ text.b.each_byte.map { |code| remap[code] }.pack('C*')
203
+ end
204
+
163
205
  def ascent(size) = ascender * size / 1000.0
164
206
 
165
207
  def descent(size) = -descender * size / 1000.0
@@ -225,10 +267,11 @@ module Acrofill
225
267
  (BOLD.match?(lower) ? 1 : 0) + (ITALIC.match?(lower) ? 2 : 0)
226
268
  end
227
269
 
228
- # Appearance text is already Windows-1252; anything else is converted so
229
- # that measuring and rendering agree byte for byte.
270
+ # Appearance text is already Windows-1252, or raw font codes after
271
+ # #encode; anything else is converted so that measuring and rendering
272
+ # agree byte for byte.
230
273
  def self.to_win_ansi(str)
231
- return str if str.encoding == WINDOWS_1252
274
+ return str if BYTE_ENCODINGS.include?(str.encoding)
232
275
 
233
276
  str.encode(WINDOWS_1252, invalid: :replace, undef: :replace, replace: '?')
234
277
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acrofill
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - stiig