fillform 0.1.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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE +26 -0
- data/README.md +126 -0
- data/data/afm/Courier-Bold.afm +342 -0
- data/data/afm/Courier-BoldOblique.afm +342 -0
- data/data/afm/Courier-Oblique.afm +342 -0
- data/data/afm/Courier.afm +342 -0
- data/data/afm/Helvetica-Bold.afm +2827 -0
- data/data/afm/Helvetica-BoldOblique.afm +2827 -0
- data/data/afm/Helvetica-Oblique.afm +3051 -0
- data/data/afm/Helvetica.afm +3051 -0
- data/data/afm/MustRead.html +19 -0
- data/data/afm/Symbol.afm +213 -0
- data/data/afm/Times-Bold.afm +2588 -0
- data/data/afm/Times-BoldItalic.afm +2384 -0
- data/data/afm/Times-Italic.afm +2667 -0
- data/data/afm/Times-Roman.afm +2419 -0
- data/data/afm/ZapfDingbats.afm +225 -0
- data/data/agl/LICENSE.md +26 -0
- data/data/agl/glyphlist.txt +4325 -0
- data/data/agl/zapfdingbats.txt +245 -0
- data/lib/fillform/appearance.rb +315 -0
- data/lib/fillform/border.rb +135 -0
- data/lib/fillform/data_format.rb +45 -0
- data/lib/fillform/field.rb +15 -0
- data/lib/fillform/filler.rb +82 -0
- data/lib/fillform/flattener.rb +114 -0
- data/lib/fillform/float32.rb +12 -0
- data/lib/fillform/font.rb +165 -0
- data/lib/fillform/form.rb +165 -0
- data/lib/fillform/glyphs.rb +99 -0
- data/lib/fillform/line_breaker.rb +98 -0
- data/lib/fillform/merged_widget.rb +20 -0
- data/lib/fillform/pdf/document.rb +240 -0
- data/lib/fillform/pdf/filters.rb +108 -0
- data/lib/fillform/pdf/objects.rb +48 -0
- data/lib/fillform/pdf/parser.rb +205 -0
- data/lib/fillform/pdf/serializer.rb +47 -0
- data/lib/fillform/pdf/text.rb +47 -0
- data/lib/fillform/pdf/writer.rb +51 -0
- data/lib/fillform/pdf.rb +9 -0
- data/lib/fillform/pdf_forms.rb +15 -0
- data/lib/fillform/pdftk_wrapper.rb +81 -0
- data/lib/fillform/report.rb +180 -0
- data/lib/fillform/version.rb +5 -0
- data/lib/fillform.rb +65 -0
- metadata +92 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Fillform
|
|
6
|
+
module PDF
|
|
7
|
+
# Reads PDF objects and content stream operations from bytes.
|
|
8
|
+
class Parser
|
|
9
|
+
WHITESPACE = /(?:[\0\t\n\f\r ]+|%[^\r\n]*)+/
|
|
10
|
+
REGULAR = %r{[^\0\t\n\f\r ()<>\[\]{}/%]+}
|
|
11
|
+
NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)\z/
|
|
12
|
+
REFERENCE = %r{(\d+)[\0\t\n\f\r ]+(\d+)[\0\t\n\f\r ]+R(?=[\0\t\n\f\r ()<>\[\]{}/%]|\z)}
|
|
13
|
+
ESCAPES = { "n" => "\n", "r" => "\r", "t" => "\t", "b" => "\b", "f" => "\f" }.freeze
|
|
14
|
+
Keyword = Struct.new(:name)
|
|
15
|
+
|
|
16
|
+
attr_reader :scanner
|
|
17
|
+
|
|
18
|
+
# The length resolver turns an indirect /Length of a stream into a number.
|
|
19
|
+
def initialize(data, position = 0, &length_resolver)
|
|
20
|
+
@scanner = StringScanner.new(data)
|
|
21
|
+
@scanner.pos = position
|
|
22
|
+
@length_resolver = length_resolver
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def position = @scanner.pos
|
|
26
|
+
|
|
27
|
+
# An indirect object at the current position: [number, generation, value].
|
|
28
|
+
def parse_indirect
|
|
29
|
+
skip_whitespace
|
|
30
|
+
@scanner.scan(/(\d+)\s+(\d+)\s+obj\b/) or raise MalformedError, "no object at #{position}"
|
|
31
|
+
number = @scanner[1].to_i
|
|
32
|
+
generation = @scanner[2].to_i
|
|
33
|
+
[number, generation, stream_after(parse_object)]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_object
|
|
37
|
+
token = next_token
|
|
38
|
+
raise MalformedError, "unexpected end of data" if token.equal?(:eof)
|
|
39
|
+
if token.is_a?(Keyword) && !token.name.match?(/\A(true|false|null)\z/)
|
|
40
|
+
raise MalformedError,
|
|
41
|
+
"unexpected #{token.name}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
token.is_a?(Keyword) ? keyword_value(token.name) : token
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Content stream operations: [operator, operands].
|
|
48
|
+
def each_operation
|
|
49
|
+
operands = []
|
|
50
|
+
loop do
|
|
51
|
+
token = next_token
|
|
52
|
+
break if token.equal?(:eof)
|
|
53
|
+
|
|
54
|
+
if token.is_a?(Keyword) && !%w[true false null].include?(token.name)
|
|
55
|
+
yield token.name.to_sym, operands
|
|
56
|
+
skip_inline_image if token.name == "ID"
|
|
57
|
+
operands = []
|
|
58
|
+
else
|
|
59
|
+
operands << (token.is_a?(Keyword) ? keyword_value(token.name) : token)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def next_token
|
|
65
|
+
skip_whitespace
|
|
66
|
+
return :eof if @scanner.eos?
|
|
67
|
+
|
|
68
|
+
return Reference.new(@scanner[1].to_i, @scanner[2].to_i) if @scanner.scan(REFERENCE)
|
|
69
|
+
|
|
70
|
+
case @scanner.peek(1)
|
|
71
|
+
when "/" then name
|
|
72
|
+
when "(" then literal_string
|
|
73
|
+
when "<" then @scanner.peek(2) == "<<" ? dictionary : hex_string
|
|
74
|
+
when "[" then array
|
|
75
|
+
when "]", ">", ")", "{", "}"
|
|
76
|
+
raise MalformedError, "unexpected #{@scanner.getch} at #{position}"
|
|
77
|
+
else word
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def skip_whitespace = @scanner.skip(WHITESPACE)
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def keyword_value(word)
|
|
86
|
+
{ "true" => true, "false" => false, "null" => nil }.fetch(word)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def word
|
|
90
|
+
text = @scanner.scan(REGULAR) or raise MalformedError, "unexpected #{@scanner.getch.inspect} at #{position}"
|
|
91
|
+
return Keyword.new(text) unless text.match?(NUMBER)
|
|
92
|
+
|
|
93
|
+
text.include?(".") ? text.to_f : text.to_i
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def name
|
|
97
|
+
@scanner.getch
|
|
98
|
+
text = (@scanner.scan(REGULAR) || "").b
|
|
99
|
+
text.gsub(/#(\h\h)/) { ::Regexp.last_match(1).hex.chr }.to_sym
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def array
|
|
103
|
+
@scanner.getch
|
|
104
|
+
result = []
|
|
105
|
+
loop do
|
|
106
|
+
skip_whitespace
|
|
107
|
+
raise MalformedError, "unterminated array" if @scanner.eos?
|
|
108
|
+
break @scanner.getch if @scanner.peek(1) == "]"
|
|
109
|
+
|
|
110
|
+
result << parse_object
|
|
111
|
+
end
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def dictionary
|
|
116
|
+
@scanner.pos += 2
|
|
117
|
+
result = {}
|
|
118
|
+
loop do
|
|
119
|
+
skip_whitespace
|
|
120
|
+
raise MalformedError, "unterminated dictionary" if @scanner.eos?
|
|
121
|
+
break @scanner.pos += 2 if @scanner.peek(2) == ">>"
|
|
122
|
+
|
|
123
|
+
key = next_token
|
|
124
|
+
raise MalformedError, "dictionary key #{key.inspect} is not a name" unless key.is_a?(Symbol)
|
|
125
|
+
|
|
126
|
+
skip_whitespace
|
|
127
|
+
result[key] = @scanner.peek(2) == ">>" ? nil : parse_object
|
|
128
|
+
end
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def hex_string
|
|
133
|
+
@scanner.getch
|
|
134
|
+
digits = @scanner.scan_until(/>/) or raise MalformedError, "unterminated hex string"
|
|
135
|
+
digits = digits.delete("^0-9A-Fa-f")
|
|
136
|
+
[digits.length.odd? ? "#{digits}0" : digits].pack("H*")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def literal_string
|
|
140
|
+
@scanner.getch
|
|
141
|
+
result = +"".b
|
|
142
|
+
depth = 1
|
|
143
|
+
loop do
|
|
144
|
+
chunk = @scanner.scan(/[^()\\]+/)
|
|
145
|
+
result << chunk.b.gsub(/\r\n?/, "\n") if chunk
|
|
146
|
+
char = @scanner.getch or raise MalformedError, "unterminated string"
|
|
147
|
+
case char
|
|
148
|
+
when "(" then depth += 1
|
|
149
|
+
result << "("
|
|
150
|
+
when ")"
|
|
151
|
+
depth -= 1
|
|
152
|
+
break if depth.zero?
|
|
153
|
+
|
|
154
|
+
result << ")"
|
|
155
|
+
else result << escape
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def escape
|
|
162
|
+
if (octal = @scanner.scan(/[0-7]{1,3}/))
|
|
163
|
+
(octal.to_i(8) & 0xFF).chr
|
|
164
|
+
elsif @scanner.skip(/\r\n?|\n/)
|
|
165
|
+
""
|
|
166
|
+
else
|
|
167
|
+
char = @scanner.getch.to_s
|
|
168
|
+
ESCAPES.fetch(char, char).b
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def stream_after(value)
|
|
173
|
+
return value unless value.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
skip_whitespace
|
|
176
|
+
return value unless @scanner.skip(/stream\r?\n/)
|
|
177
|
+
|
|
178
|
+
Stream.new(value, stream_data(value))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def stream_data(dictionary)
|
|
182
|
+
start = position
|
|
183
|
+
length = dictionary[:Length]
|
|
184
|
+
length = @length_resolver&.call(length) if length.is_a?(Reference)
|
|
185
|
+
if length_valid?(start, length)
|
|
186
|
+
@scanner.pos = start + length
|
|
187
|
+
elsif !@scanner.scan_until(/(?=\r?\n?endstream)/)
|
|
188
|
+
raise MalformedError, "unterminated stream"
|
|
189
|
+
end
|
|
190
|
+
data = @scanner.string.byteslice(start, position - start)
|
|
191
|
+
@scanner.skip(/\s*endstream/)
|
|
192
|
+
data
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def length_valid?(start, length)
|
|
196
|
+
length.is_a?(Integer) && @scanner.string.byteslice(start + length, 30).to_s.match?(/\A\s*endstream/)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def skip_inline_image
|
|
200
|
+
@scanner.getch
|
|
201
|
+
@scanner.scan_until(/[\0\t\n\f\r ]EI(?=[\0\t\n\f\r ]|\z)/)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
module PDF
|
|
5
|
+
# Writes PDF objects as bytes.
|
|
6
|
+
module Serializer
|
|
7
|
+
NAME_ESCAPE = %r{[^!-~]|[#()<>\[\]{}/%]}n
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def serialize(value)
|
|
12
|
+
case value
|
|
13
|
+
when nil then "null"
|
|
14
|
+
when true then "true"
|
|
15
|
+
when false then "false"
|
|
16
|
+
when Integer, Reference then value.to_s
|
|
17
|
+
when Float then number(value)
|
|
18
|
+
when Symbol then name(value)
|
|
19
|
+
when String then string(value)
|
|
20
|
+
when Array then "[#{value.map { |item| serialize(item) }.join(" ")}]"
|
|
21
|
+
when Hash then dictionary(value)
|
|
22
|
+
else raise ArgumentError, "cannot serialize #{value.class}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def number(value)
|
|
27
|
+
return "0" unless value.finite?
|
|
28
|
+
|
|
29
|
+
text = value.to_s
|
|
30
|
+
text = format("%.12f", value).sub(/\.?0+\z/, "") if text.include?("e")
|
|
31
|
+
text == "-0" ? "0" : text
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def name(value)
|
|
35
|
+
"/#{value.to_s.b.gsub(NAME_ESCAPE) { |char| format("#%02X", char.ord) }}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def string(value)
|
|
39
|
+
"(#{value.b.gsub(/[()\\\r]/n) { |char| char == "\r" ? "\\r" : "\\#{char}" }})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dictionary(value)
|
|
43
|
+
"<<#{value.map { |key, item| "#{name(key)} #{serialize(item)}" }.join(" ")}>>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
module PDF
|
|
5
|
+
# Text strings: PDFDocEncoding, or UTF-16BE (and UTF-8 since PDF 2.0) with a byte order mark.
|
|
6
|
+
module Text
|
|
7
|
+
# The bytes where PDFDocEncoding differs from ISO-8859-1 (PDF 32000-1, annex D.2).
|
|
8
|
+
PDF_DOC_DIFFERENCES = {
|
|
9
|
+
0x18 => 0x02D8, 0x19 => 0x02C7, 0x1A => 0x02C6, 0x1B => 0x02D9, 0x1C => 0x02DD, 0x1D => 0x02DB,
|
|
10
|
+
0x1E => 0x02DA, 0x1F => 0x02DC, 0x7F => nil, 0x80 => 0x2022, 0x81 => 0x2020, 0x82 => 0x2021,
|
|
11
|
+
0x83 => 0x2026, 0x84 => 0x2014, 0x85 => 0x2013, 0x86 => 0x0192, 0x87 => 0x2044, 0x88 => 0x2039,
|
|
12
|
+
0x89 => 0x203A, 0x8A => 0x2212, 0x8B => 0x2030, 0x8C => 0x201E, 0x8D => 0x201C, 0x8E => 0x201D,
|
|
13
|
+
0x8F => 0x2018, 0x90 => 0x2019, 0x91 => 0x201A, 0x92 => 0x2122, 0x93 => 0xFB01, 0x94 => 0xFB02,
|
|
14
|
+
0x95 => 0x0141, 0x96 => 0x0152, 0x97 => 0x0160, 0x98 => 0x0178, 0x99 => 0x017D, 0x9A => 0x0131,
|
|
15
|
+
0x9B => 0x0142, 0x9C => 0x0153, 0x9D => 0x0161, 0x9E => 0x017E, 0x9F => nil, 0xA0 => 0x20AC, 0xAD => nil
|
|
16
|
+
}.freeze
|
|
17
|
+
PDF_DOC = Array.new(256) { |byte| PDF_DOC_DIFFERENCES.fetch(byte, byte)&.chr(Encoding::UTF_8) }.freeze
|
|
18
|
+
PDF_DOC_BYTES = PDF_DOC.each_with_index.filter_map { |char, byte| [char, byte.chr] if char }.to_h.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def decode(bytes)
|
|
23
|
+
bytes = bytes.b
|
|
24
|
+
if bytes.start_with?("\xFE\xFF".b)
|
|
25
|
+
bytes.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8, invalid: :replace)
|
|
26
|
+
elsif bytes.start_with?("\xEF\xBB\xBF".b)
|
|
27
|
+
bytes.byteslice(3..).force_encoding(Encoding::UTF_8).scrub
|
|
28
|
+
else
|
|
29
|
+
bytes.each_byte.map { |byte| PDF_DOC[byte] || "�" }.join
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pdf_doc_char(byte) = PDF_DOC[byte]
|
|
34
|
+
|
|
35
|
+
def encode(text)
|
|
36
|
+
text = text.to_s.encode(Encoding::UTF_8)
|
|
37
|
+
if text.each_char.all? { PDF_DOC_BYTES.key?(_1) }
|
|
38
|
+
return text.each_char.map do |char|
|
|
39
|
+
PDF_DOC_BYTES[char]
|
|
40
|
+
end.join.b
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
"\xFE\xFF".b + text.encode(Encoding::UTF_16BE).b
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
module PDF
|
|
5
|
+
# Writes the given objects of a document as a single revision with a cross-reference table.
|
|
6
|
+
class Writer
|
|
7
|
+
def initialize(document, numbers)
|
|
8
|
+
@document = document
|
|
9
|
+
@numbers = numbers
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_pdf
|
|
13
|
+
out = "%PDF-#{version}\n%\xE2\xE3\xCF\xD3\n".b
|
|
14
|
+
offsets = {}
|
|
15
|
+
@numbers.each do |number|
|
|
16
|
+
offsets[number] = out.bytesize
|
|
17
|
+
out << "#{number} 0 obj\n".b << body(@document.object(number)) << "\nendobj\n".b
|
|
18
|
+
end
|
|
19
|
+
size = (@numbers.max || 0) + 1
|
|
20
|
+
xref = out.bytesize
|
|
21
|
+
out << cross_references(offsets, size)
|
|
22
|
+
out << "trailer\n#{Serializer.serialize(trailer(size))}\nstartxref\n#{xref}\n%%EOF\n".b
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def version
|
|
28
|
+
catalog_version = @document.catalog[:Version].to_s
|
|
29
|
+
[catalog_version, @document.version].max
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def body(value)
|
|
33
|
+
return Serializer.serialize(value).b unless value.is_a?(Stream)
|
|
34
|
+
|
|
35
|
+
dictionary = value.dictionary.merge(Length: value.raw.bytesize)
|
|
36
|
+
"#{Serializer.serialize(dictionary)}\nstream\n".b << value.raw.b << "\nendstream".b
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cross_references(offsets, size)
|
|
40
|
+
lines = Array.new(size) do |number|
|
|
41
|
+
offsets.key?(number) ? format("%010d 00000 n \n", offsets[number]) : "0000000000 65535 f \n"
|
|
42
|
+
end
|
|
43
|
+
"xref\n0 #{size}\n#{lines.join}".b
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def trailer(size)
|
|
47
|
+
@document.trailer.slice(:Root, :Info, :ID).merge(Size: size)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/fillform/pdf.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fillform"
|
|
4
|
+
require_relative "data_format"
|
|
5
|
+
require_relative "pdftk_wrapper"
|
|
6
|
+
|
|
7
|
+
# The API of the pdf-forms gem, backed by Fillform. `gem "fillform", require: "fillform/pdf_forms"`
|
|
8
|
+
# replaces `gem "pdf-forms"` without changing the code that calls it.
|
|
9
|
+
module PdfForms
|
|
10
|
+
PdftkError = Fillform::Error
|
|
11
|
+
|
|
12
|
+
def self.new(*)
|
|
13
|
+
Fillform::PdftkWrapper.new(*)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
class PdftkWrapper
|
|
5
|
+
UNSUPPORTED_OPTIONS = %i[encrypt encrypt_password encrypt_options replacement_font drop_xfa drop_xmp
|
|
6
|
+
need_appearances].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :options
|
|
9
|
+
|
|
10
|
+
# The path of the pdftk executable, if given, is ignored: pdftk is no longer needed.
|
|
11
|
+
def initialize(pdftk_or_options = nil, options = {})
|
|
12
|
+
@options = pdftk_or_options.is_a?(Hash) ? pdftk_or_options : options
|
|
13
|
+
check_options(@options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The values change on their way into the form like they do through the pdf-forms data format.
|
|
17
|
+
def fill_form(template, destination, data = {}, fill_options = {})
|
|
18
|
+
check_options(fill_options)
|
|
19
|
+
flatten = fill_options.fetch(:flatten, options[:flatten])
|
|
20
|
+
Fillform.fill(template, destination, encode(data), flatten: flatten ? true : false)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def read(path)
|
|
24
|
+
Pdf.new(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_fields(template)
|
|
28
|
+
read(template).fields
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_field_names(template)
|
|
32
|
+
get_fields(template).map(&:name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def encode(data)
|
|
38
|
+
data.each_with_object({}) do |(key, value), result|
|
|
39
|
+
if value.is_a?(Hash)
|
|
40
|
+
value.each { |sub_key, sub_value| result[name("#{key}_#{sub_key}")] = encode_value(sub_value) }
|
|
41
|
+
else
|
|
42
|
+
result[name(key.to_s)] = encode_value(value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format = options[:data_format] || "Fdf"
|
|
48
|
+
|
|
49
|
+
def name(key) = DataFormat.field_name(format, key)
|
|
50
|
+
|
|
51
|
+
def encode_value(value)
|
|
52
|
+
# pdf-forms writes the values of an array in one XFDF value, separated by spaces.
|
|
53
|
+
value = value.join(" ") if value.is_a?(Array) && format.to_s == "XFdf"
|
|
54
|
+
return value.map { |item| DataFormat.apply(format, item.to_s) } if value.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
DataFormat.apply(format, value.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_options(options)
|
|
60
|
+
unsupported = options.keys.map(&:to_sym) & UNSUPPORTED_OPTIONS
|
|
61
|
+
raise Error, "Fillform does not support the pdftk options #{unsupported.join(", ")}" if unsupported.any?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Pdf
|
|
66
|
+
attr_reader :path
|
|
67
|
+
|
|
68
|
+
def initialize(path)
|
|
69
|
+
@path = path.to_s
|
|
70
|
+
raise IOError, "#{@path} is not readable" unless File.readable?(@path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fields
|
|
74
|
+
@fields ||= Fillform.fields(path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def field(name)
|
|
78
|
+
fields.find { |field| field.name == name }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# The field report of `pdftk dump_data_fields`, as pdf-forms parses it: pdftk's ReportAcroFormFields,
|
|
5
|
+
# which walks the field tree with an accumulated state and reports a node when its kids are unnamed.
|
|
6
|
+
class Report
|
|
7
|
+
State = Struct.new(:type, :name, :name_alt, :flags, :field_values, :default, :justification, :max_length,
|
|
8
|
+
:option_list, :states, :pairs) do
|
|
9
|
+
def self.empty = new("", "", "", 0, [], "", 0, 0, false, [], [])
|
|
10
|
+
|
|
11
|
+
def copy = State.new(*to_a.map { |value| value.is_a?(Array) ? value.dup : value })
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
TYPES = { Btn: "Button", Tx: "Text", Ch: "Choice", Sig: "Signature" }.freeze
|
|
15
|
+
JUSTIFICATIONS = %w[Left Center Right].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(document)
|
|
18
|
+
@document = document
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fields
|
|
22
|
+
@fields = []
|
|
23
|
+
form = @document.deref(@document.catalog&.[](:AcroForm))
|
|
24
|
+
kids = @document.deref(form[:Fields]) if form.is_a?(Hash)
|
|
25
|
+
walk(kids, State.empty, 0) if kids.is_a?(Array)
|
|
26
|
+
@fields
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Returns whether any kid has a name.
|
|
32
|
+
def walk(kids, state, depth)
|
|
33
|
+
saved = state.copy
|
|
34
|
+
named = false
|
|
35
|
+
kids.each do |kid|
|
|
36
|
+
node = @document.deref(kid)
|
|
37
|
+
next unless node.is_a?(Hash)
|
|
38
|
+
next unless read?(node, state)
|
|
39
|
+
|
|
40
|
+
if (children = @document.deref(node[:Kids])).is_a?(Array) && depth < 32
|
|
41
|
+
children_named = walk(children, state, depth + 1)
|
|
42
|
+
output(state) if !children_named && name?(node)
|
|
43
|
+
restore(state, saved)
|
|
44
|
+
named ||= children_named
|
|
45
|
+
elsif name?(node)
|
|
46
|
+
output(state)
|
|
47
|
+
restore(state, saved)
|
|
48
|
+
named = true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
named
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reads the attributes of a node into the state; false when its value is a null object.
|
|
55
|
+
def read?(node, state)
|
|
56
|
+
type = value(node, :FT)
|
|
57
|
+
state.type = TYPES.fetch(type, state.type) if type.is_a?(Symbol)
|
|
58
|
+
title = value(node, :T)
|
|
59
|
+
if title.is_a?(String)
|
|
60
|
+
state.name = [state.name.empty? ? nil : state.name,
|
|
61
|
+
PDF::Text.decode(title)].compact.join(".")
|
|
62
|
+
end
|
|
63
|
+
alternate = value(node, :TU)
|
|
64
|
+
state.name_alt = alternate.is_a?(String) ? PDF::Text.decode(alternate) : ""
|
|
65
|
+
state.flags = value(node, :Ff).to_i if value(node, :Ff).is_a?(Numeric)
|
|
66
|
+
return false unless read_value?(node, state)
|
|
67
|
+
|
|
68
|
+
read_attributes(node, state)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_value?(node, state)
|
|
73
|
+
return true unless node.key?(:V)
|
|
74
|
+
|
|
75
|
+
value = value(node, :V)
|
|
76
|
+
return false if value.nil?
|
|
77
|
+
|
|
78
|
+
Array(value.is_a?(Array) ? value : [value]).each do |item|
|
|
79
|
+
text = text_or_name(@document.deref(item))
|
|
80
|
+
state.field_values |= [text] if text
|
|
81
|
+
end
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def read_attributes(node, state)
|
|
86
|
+
default = text_or_name(value(node, :DV))
|
|
87
|
+
state.default = default if default
|
|
88
|
+
state.justification = value(node, :Q).to_i if value(node, :Q).is_a?(Numeric)
|
|
89
|
+
state.max_length = value(node, :MaxLen).to_i if value(node, :MaxLen).is_a?(Numeric)
|
|
90
|
+
read_options(value(node, :Opt), state) if value(node, :Opt).is_a?(Array)
|
|
91
|
+
appearances = value(node, :AP)
|
|
92
|
+
%i[N D R].each { |kind| read_states(@document.deref(appearances[kind]), state) } if appearances.is_a?(Hash)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def read_options(options, state)
|
|
96
|
+
state.option_list = true
|
|
97
|
+
options.each do |option|
|
|
98
|
+
option = @document.deref(option)
|
|
99
|
+
if option.is_a?(String)
|
|
100
|
+
state.states |= [PDF::Text.decode(option)]
|
|
101
|
+
elsif option.is_a?(Array)
|
|
102
|
+
texts = option.map { |item| @document.deref(item) }.grep(String)
|
|
103
|
+
state.pairs |= [texts.map { |text| PDF::Text.decode(text) }] if texts.size == 2
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# pdftk lists the states of an appearance dictionary in the iteration order of a java.util.HashMap,
|
|
109
|
+
# and only the Off state when the field has an option list.
|
|
110
|
+
def read_states(appearances, state)
|
|
111
|
+
return unless appearances.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
names = JavaHashMap.order(appearances.keys)
|
|
114
|
+
names = names.select { |name| name == :Off } if state.option_list
|
|
115
|
+
state.states |= names.map { |name| Field.latin1(name) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def restore(state, saved)
|
|
119
|
+
saved.copy.to_a.each_with_index { |value, index| state[index] = value }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def name?(node) = value(node, :T).is_a?(String)
|
|
123
|
+
|
|
124
|
+
def value(node, key) = @document.deref(node[key])
|
|
125
|
+
|
|
126
|
+
def text_or_name(value)
|
|
127
|
+
case value
|
|
128
|
+
when String then PDF::Text.decode(value)
|
|
129
|
+
when Symbol then Field.latin1(value)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# As pdf-forms reads the lines: the last value wins, leading whitespace and a trailing line end go.
|
|
134
|
+
def output(state)
|
|
135
|
+
@fields << Field.new(
|
|
136
|
+
name: state.name, type: state.type, flags: state.flags.to_s,
|
|
137
|
+
options: lines(state.states + state.pairs.map(&:first)),
|
|
138
|
+
justification: JUSTIFICATIONS.fetch(state.justification, state.justification.to_s),
|
|
139
|
+
value: line(interpret(state, state.field_values.last)),
|
|
140
|
+
value_default: line(state.default.empty? ? nil : interpret(state, state.default)),
|
|
141
|
+
name_alt: line(state.name_alt.empty? ? nil : state.name_alt),
|
|
142
|
+
max_length: (state.max_length.to_s if state.max_length.positive?)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# pdftk shows the value of a button with an option list as the option its number points to.
|
|
147
|
+
def interpret(state, value)
|
|
148
|
+
return value unless value && state.type == "Button" && state.option_list && value.match?(/\A[+-]?\d{1,9}\z/)
|
|
149
|
+
|
|
150
|
+
index = value.to_i
|
|
151
|
+
index.between?(0, state.states.size - 1) ? state.states[index] : value
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def lines(values) = values.empty? ? nil : values.map { |value| value.lstrip.chomp }
|
|
155
|
+
|
|
156
|
+
def line(value) = value&.lstrip&.chomp
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The iteration order of the keys of a java.util.HashMap holding iText PdfNames.
|
|
160
|
+
module JavaHashMap
|
|
161
|
+
ESCAPED = [32, 35, 37, 40, 41, 47, 60, 62, 91, 93, 123, 125].freeze
|
|
162
|
+
|
|
163
|
+
module_function
|
|
164
|
+
|
|
165
|
+
def order(names)
|
|
166
|
+
capacity = 16
|
|
167
|
+
capacity *= 2 while names.size > capacity * 3 / 4
|
|
168
|
+
names.each_with_index.sort_by { |name, index| [bucket(name, capacity), index] }.map(&:first)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The hash of iText's PdfName is computed over the name in iText's encoding, with the slash.
|
|
172
|
+
def bucket(name, capacity)
|
|
173
|
+
encoded = name.to_s.b.each_byte.map do |byte|
|
|
174
|
+
ESCAPED.include?(byte) || !byte.between?(32, 126) ? format("#%02x", byte) : byte.chr
|
|
175
|
+
end
|
|
176
|
+
hash = "/#{encoded.join}".each_byte.reduce(0) { |sum, byte| ((sum * 31) + byte) & 0xFFFFFFFF }
|
|
177
|
+
(hash ^ (hash >> 16)) & (capacity - 1)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|