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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE +26 -0
  4. data/README.md +126 -0
  5. data/data/afm/Courier-Bold.afm +342 -0
  6. data/data/afm/Courier-BoldOblique.afm +342 -0
  7. data/data/afm/Courier-Oblique.afm +342 -0
  8. data/data/afm/Courier.afm +342 -0
  9. data/data/afm/Helvetica-Bold.afm +2827 -0
  10. data/data/afm/Helvetica-BoldOblique.afm +2827 -0
  11. data/data/afm/Helvetica-Oblique.afm +3051 -0
  12. data/data/afm/Helvetica.afm +3051 -0
  13. data/data/afm/MustRead.html +19 -0
  14. data/data/afm/Symbol.afm +213 -0
  15. data/data/afm/Times-Bold.afm +2588 -0
  16. data/data/afm/Times-BoldItalic.afm +2384 -0
  17. data/data/afm/Times-Italic.afm +2667 -0
  18. data/data/afm/Times-Roman.afm +2419 -0
  19. data/data/afm/ZapfDingbats.afm +225 -0
  20. data/data/agl/LICENSE.md +26 -0
  21. data/data/agl/glyphlist.txt +4325 -0
  22. data/data/agl/zapfdingbats.txt +245 -0
  23. data/lib/fillform/appearance.rb +315 -0
  24. data/lib/fillform/border.rb +135 -0
  25. data/lib/fillform/data_format.rb +45 -0
  26. data/lib/fillform/field.rb +15 -0
  27. data/lib/fillform/filler.rb +82 -0
  28. data/lib/fillform/flattener.rb +114 -0
  29. data/lib/fillform/float32.rb +12 -0
  30. data/lib/fillform/font.rb +165 -0
  31. data/lib/fillform/form.rb +165 -0
  32. data/lib/fillform/glyphs.rb +99 -0
  33. data/lib/fillform/line_breaker.rb +98 -0
  34. data/lib/fillform/merged_widget.rb +20 -0
  35. data/lib/fillform/pdf/document.rb +240 -0
  36. data/lib/fillform/pdf/filters.rb +108 -0
  37. data/lib/fillform/pdf/objects.rb +48 -0
  38. data/lib/fillform/pdf/parser.rb +205 -0
  39. data/lib/fillform/pdf/serializer.rb +47 -0
  40. data/lib/fillform/pdf/text.rb +47 -0
  41. data/lib/fillform/pdf/writer.rb +51 -0
  42. data/lib/fillform/pdf.rb +9 -0
  43. data/lib/fillform/pdf_forms.rb +15 -0
  44. data/lib/fillform/pdftk_wrapper.rb +81 -0
  45. data/lib/fillform/report.rb +180 -0
  46. data/lib/fillform/version.rb +5 -0
  47. data/lib/fillform.rb +65 -0
  48. 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pdf/objects"
4
+ require_relative "pdf/filters"
5
+ require_relative "pdf/parser"
6
+ require_relative "pdf/text"
7
+ require_relative "pdf/serializer"
8
+ require_relative "pdf/writer"
9
+ require_relative "pdf/document"
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fillform
4
+ VERSION = "0.1.0"
5
+ end