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 +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +9 -5
- data/lib/acrofill/appearance.rb +34 -29
- data/lib/acrofill/document.rb +24 -2
- data/lib/acrofill/flattener.rb +159 -0
- data/lib/acrofill/form.rb +14 -140
- data/lib/acrofill/metrics.rb +160 -17
- data/lib/acrofill/version.rb +1 -1
- data/lib/acrofill.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3fd7c0e555d7ae744ba74b2c4bde970f938db256eb22d364584d1923650fa8a
|
|
4
|
+
data.tar.gz: 2f1f9205e4562e4dd6b483fa2a13a3855cb167c6c3d9b18b92871b3af87a2589
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
166
|
-
(Helvetica/Courier
|
|
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.
|
data/lib/acrofill/appearance.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
77
|
-
text_width = Metrics.string_width(text,
|
|
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,
|
|
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),
|
|
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,
|
|
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,
|
|
109
|
-
space = Metrics.string_width(' ',
|
|
108
|
+
def wrap_line(text, widths, size, max_width)
|
|
109
|
+
space = Metrics.string_width(' ', widths, size)
|
|
110
110
|
lines = ['']
|
|
111
|
-
|
|
111
|
+
so_far = [0.0]
|
|
112
112
|
text.split.each do |word|
|
|
113
|
-
word_width = Metrics.string_width(word,
|
|
113
|
+
word_width = Metrics.string_width(word, widths, size)
|
|
114
114
|
if lines.last.empty?
|
|
115
115
|
lines[-1] = word
|
|
116
|
-
|
|
117
|
-
elsif
|
|
116
|
+
so_far[-1] = word_width
|
|
117
|
+
elsif so_far.last + space + word_width <= max_width
|
|
118
118
|
lines[-1] = "#{lines.last} #{word}"
|
|
119
|
-
|
|
119
|
+
so_far[-1] += space + word_width
|
|
120
120
|
else
|
|
121
121
|
lines << word
|
|
122
|
-
|
|
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,
|
|
185
|
+
def shrink_to_fit(text, widths, size, width)
|
|
186
186
|
max_width = width - (2 * PADDING)
|
|
187
|
-
text_width = Metrics.string_width(text,
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
211
|
+
entry ? @doc.ref_for(entry) : fallback_font
|
|
212
|
+
end
|
|
211
213
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
# Font not present in /DR: register one plain Helvetica and share it
|
|
215
|
+
# across every appearance built from this form.
|
|
216
|
+
def fallback_font
|
|
217
|
+
@fallback_font ||=
|
|
218
|
+
@doc.add(Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding)
|
|
214
219
|
end
|
|
215
220
|
|
|
216
221
|
def printable_text(value)
|
data/lib/acrofill/document.rb
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
data/lib/acrofill/metrics.rb
CHANGED
|
@@ -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' => [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
def self.string_width(str,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
code
|
|
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
|
data/lib/acrofill/version.rb
CHANGED
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.
|
|
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
|