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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# The changes pdf-forms and pdftk make to the values and the field names on their way into the
|
|
5
|
+
# form, per data format.
|
|
6
|
+
module DataFormat
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def apply(format, value)
|
|
10
|
+
case format.to_s
|
|
11
|
+
when "XFdf" then xfdf(value)
|
|
12
|
+
when "FdfHex" then value
|
|
13
|
+
else fdf(value)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The name pdftk looks the field up with. In FDF the names are literal strings that pdftk reads in
|
|
18
|
+
# PDFDocEncoding, so a field whose name is not in PDFDocEncoding is not found and not filled.
|
|
19
|
+
def field_name(format, name)
|
|
20
|
+
case format.to_s
|
|
21
|
+
when "XFdf" then name
|
|
22
|
+
when "FdfHex" then PDF::Text.decode(name.b)
|
|
23
|
+
else latin9_as_pdf_doc(name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fdf, the default of pdf-forms: the values are written in ISO-8859-15 and read back by pdftk in
|
|
28
|
+
# PDFDocEncoding, a line feed is written as an escaped carriage return and a raw carriage return
|
|
29
|
+
# is read as a line feed.
|
|
30
|
+
def fdf(value)
|
|
31
|
+
latin9_as_pdf_doc(value).gsub(/[\r\n]/, "\r" => "\n", "\n" => "\r")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# XFdf: pdftk's XML parser collapses a run of the same space, tab or line feed into one and reads
|
|
35
|
+
# carriage returns as line feeds.
|
|
36
|
+
def xfdf(value)
|
|
37
|
+
value.gsub(/([ \t\n])\1+/, '\1').gsub(/\r\n?/, "\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def latin9_as_pdf_doc(text)
|
|
41
|
+
latin = text.encode(Encoding::ISO_8859_15, invalid: :replace, undef: :replace, replace: "?")
|
|
42
|
+
latin.b.each_byte.map { |byte| PDF::Text.pdf_doc_char(byte) || "�" }.join
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# The metadata of a form field, with the attributes and string values pdf-forms reads from
|
|
5
|
+
# `pdftk dump_data_fields`. See Report for how pdftk walks the field tree.
|
|
6
|
+
Field = Struct.new(:name, :type, :options, :flags, :justification, :value, :value_default, :name_alt,
|
|
7
|
+
:max_length, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
class Field
|
|
10
|
+
# pdftk reads the bytes of a name as Latin-1.
|
|
11
|
+
def self.latin1(name) = name.to_s.b.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
|
|
12
|
+
|
|
13
|
+
def to_hash = to_h
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
class Filler
|
|
5
|
+
def initialize(template)
|
|
6
|
+
@doc = PDF::Document.open(template.to_s)
|
|
7
|
+
@form = Form.new(@doc)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def fill(data, flatten: false)
|
|
11
|
+
return self unless @form.present?
|
|
12
|
+
|
|
13
|
+
fields = @form.fields.to_h { |field| [field.name, field] }
|
|
14
|
+
data.each do |name, value|
|
|
15
|
+
field = fields[name.to_s]
|
|
16
|
+
next unless field
|
|
17
|
+
|
|
18
|
+
case field.concrete_type
|
|
19
|
+
when :check_box, :radio_button then select_state(field, value.to_s)
|
|
20
|
+
when :text_field then appearance(field).fill(value.to_s)
|
|
21
|
+
when :combo_box then appearance(field).fill_choice(value.to_s)
|
|
22
|
+
when :list_box then appearance(field).fill_list(Array(value).map(&:to_s))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
Flattener.new(@doc, @form).flatten if flatten
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Not validated: like pdftk, a value longer than the field's MaxLen is kept as it is.
|
|
30
|
+
def write(destination)
|
|
31
|
+
@doc.write(destination.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Like pdftk, an unknown state leaves the button as it is, and the template's own appearance of
|
|
37
|
+
# the state is drawn.
|
|
38
|
+
def select_state(field, value)
|
|
39
|
+
state = value == "Off" ? :Off : on_states(field).find { |name| state_name?(name, value) }
|
|
40
|
+
return unless state
|
|
41
|
+
|
|
42
|
+
field[:V] = state
|
|
43
|
+
field.widgets.each do |widget|
|
|
44
|
+
appearances = widget.normal_appearances
|
|
45
|
+
widget[:AS] = appearances.is_a?(Hash) && appearances.key?(state) ? state : :Off
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_states(field)
|
|
50
|
+
field.widgets.flat_map do |widget|
|
|
51
|
+
appearances = widget.normal_appearances
|
|
52
|
+
appearances.is_a?(Hash) ? appearances.keys : []
|
|
53
|
+
end.uniq - [:Off]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# pdftk shows and matches the bytes of a state name as Latin-1, so its users pass "Sì" for "Sì".
|
|
57
|
+
def state_name?(name, value)
|
|
58
|
+
bytes = name.to_s.b
|
|
59
|
+
utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
60
|
+
(utf8.valid_encoding? && utf8 == value) || Field.latin1(name) == value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def appearance(field)
|
|
64
|
+
Appearance.new(@doc, field, form: @form) { |resources, name| font(resources, name) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# iText draws with Helvetica when the font of the field is not in its resources.
|
|
68
|
+
def font(resources, name)
|
|
69
|
+
@fonts ||= {}
|
|
70
|
+
@fonts[[resources.object_id, name]] ||= begin
|
|
71
|
+
fonts = @doc.deref(resources[:Font]) if resources.is_a?(Hash)
|
|
72
|
+
resource = fonts[name.to_sym] if fonts.is_a?(Hash) && name
|
|
73
|
+
@doc.deref(resource).is_a?(Hash) ? Font.new(name.to_sym, resource, @doc) : helvetica
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def helvetica
|
|
78
|
+
@helvetica ||= Font.new(:Helv, @doc.add({ Type: :Font, Subtype: :Type1, BaseFont: :Helvetica,
|
|
79
|
+
Encoding: :WinAnsiEncoding }), @doc, fallback: true)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# Draws the appearances of the fields into their pages and removes the fields, like iText's
|
|
5
|
+
# PdfStamperImp#flatFields: in the order of the field names, each appearance moved to the lower left
|
|
6
|
+
# corner of its widget, only for widgets that are printed and not hidden.
|
|
7
|
+
class Flattener
|
|
8
|
+
HIDDEN = 1 << 1
|
|
9
|
+
PRINT = 1 << 2
|
|
10
|
+
|
|
11
|
+
def initialize(document, form)
|
|
12
|
+
@document = document
|
|
13
|
+
@form = form
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def flatten
|
|
17
|
+
pages = @document.pages
|
|
18
|
+
page_of = page_index(pages)
|
|
19
|
+
drawings = Hash.new { |hash, key| hash[key] = [] }
|
|
20
|
+
removed = {}
|
|
21
|
+
@form.fields.sort_by(&:name).each do |field|
|
|
22
|
+
field.widgets.each do |widget|
|
|
23
|
+
index = widget.reference.is_a?(PDF::Reference) ? page_of[widget.reference.number] : nil
|
|
24
|
+
next unless index
|
|
25
|
+
|
|
26
|
+
appearance = appearance(widget) if visible?(widget)
|
|
27
|
+
drawings[index] << [appearance, *widget.rect[0, 2]] if appearance
|
|
28
|
+
removed[widget.reference.number] = true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
pages.each_with_index do |(_reference, page, inherited), index|
|
|
32
|
+
remove_widgets(page, removed)
|
|
33
|
+
draw(page, inherited, drawings[index]) if drawings.key?(index)
|
|
34
|
+
end
|
|
35
|
+
@form.dictionary[:Fields] = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def page_index(pages)
|
|
41
|
+
pages.each_with_index.with_object({}) do |((_reference, page, _inherited), index), result|
|
|
42
|
+
Array(@document.deref(page[:Annots])).each do |annotation|
|
|
43
|
+
result[annotation.number] ||= index if annotation.is_a?(PDF::Reference)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visible?(widget)
|
|
49
|
+
flags = widget[:F].to_i
|
|
50
|
+
flags.anybits?(PRINT) && !flags.anybits?(HIDDEN)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The normal appearance, or the one of the widget's state.
|
|
54
|
+
def appearance(widget)
|
|
55
|
+
appearances = widget[:AP]
|
|
56
|
+
return unless appearances.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
normal = appearances[:N]
|
|
59
|
+
value = @document.deref(normal)
|
|
60
|
+
reference = case value
|
|
61
|
+
when PDF::Stream then normal.is_a?(PDF::Reference) ? normal : @document.add(value)
|
|
62
|
+
when Hash then value[widget[:AS]] if widget[:AS]
|
|
63
|
+
end
|
|
64
|
+
stream = @document.deref(reference) if reference.is_a?(PDF::Reference)
|
|
65
|
+
return unless stream.is_a?(PDF::Stream)
|
|
66
|
+
|
|
67
|
+
stream[:Subtype] = :Form
|
|
68
|
+
reference
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def remove_widgets(page, removed)
|
|
72
|
+
annotations = Array(@document.deref(page[:Annots]))
|
|
73
|
+
kept = annotations.reject { |annotation| annotation.is_a?(PDF::Reference) && removed[annotation.number] }
|
|
74
|
+
return if kept.size == annotations.size
|
|
75
|
+
|
|
76
|
+
kept.empty? ? page.delete(:Annots) : page[:Annots] = kept
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def draw(page, inherited, drawings)
|
|
80
|
+
xobjects = xobjects(page, inherited)
|
|
81
|
+
operations = drawings.map do |reference, left, bottom|
|
|
82
|
+
name = next_name(xobjects)
|
|
83
|
+
xobjects[name] = reference
|
|
84
|
+
"q 1 0 0 1 #{Fillform.number(left)} #{Fillform.number(bottom)} cm #{PDF::Serializer.name(name)} Do Q\n"
|
|
85
|
+
end
|
|
86
|
+
page[:Contents] = [@document.add(PDF::Stream.new({}, "q\n".b)), *contents(page),
|
|
87
|
+
@document.add(PDF::Stream.new({}, "\nQ\n#{operations.join}".b))]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def contents(page)
|
|
91
|
+
contents = page[:Contents]
|
|
92
|
+
value = @document.deref(contents)
|
|
93
|
+
case value
|
|
94
|
+
when Array then value
|
|
95
|
+
when nil then []
|
|
96
|
+
else [contents]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def xobjects(page, inherited)
|
|
101
|
+
page[:Resources] = @document.deref(inherited[:Resources]).dup || {} unless page.key?(:Resources)
|
|
102
|
+
resources = @document.deref(page[:Resources])
|
|
103
|
+
xobjects = @document.deref(resources[:XObject])
|
|
104
|
+
resources[:XObject] = xobjects = {} unless xobjects.is_a?(Hash)
|
|
105
|
+
xobjects
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def next_name(xobjects)
|
|
109
|
+
index = 0
|
|
110
|
+
index += 1 while xobjects.key?(:"Xi#{index}")
|
|
111
|
+
:"Xi#{index}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# iText computes the layout with Java floats: each operation is rounded to single precision.
|
|
5
|
+
module Float32
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def f(value) = [value].pack("e").unpack1("e")
|
|
9
|
+
|
|
10
|
+
THOUSANDTH = f(0.001)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# The metrics and the encoding iText reads from a font of the form's resources.
|
|
5
|
+
class Font
|
|
6
|
+
# iText encodes with the Java charsets of these encodings; any other encoding goes by glyph names.
|
|
7
|
+
CHARSETS = { WinAnsiEncoding: Encoding::Windows_1252, MacRomanEncoding: Encoding::MacRoman }.freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :key, :resource
|
|
10
|
+
|
|
11
|
+
# iText's own fallback font drops the characters it cannot show, a font of the form shows code 0.
|
|
12
|
+
# The resource is what the appearance stream lists for the font: a reference or a dictionary.
|
|
13
|
+
def initialize(key, resource, document, fallback: false)
|
|
14
|
+
@key = key
|
|
15
|
+
@resource = resource
|
|
16
|
+
@document = document
|
|
17
|
+
@dictionary = document.deref(resource)
|
|
18
|
+
@missing = fallback ? nil : 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def encode(char)
|
|
22
|
+
@codes ||= {}
|
|
23
|
+
return @codes[char] if @codes.key?(char)
|
|
24
|
+
|
|
25
|
+
@codes[char] = differences[char] || charset_code(char) || (charset ? nil : base_codes[char]) || @missing
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def encode_all(chars) = chars.filter_map { |char| encode(char) }
|
|
29
|
+
|
|
30
|
+
# iText's BaseFont#getWidthPoint with the integer width of the glyph.
|
|
31
|
+
def char_width(char, size)
|
|
32
|
+
code = encode(char)
|
|
33
|
+
f(f(f(code ? width(code).to_i : 0) * Float32::THOUSANDTH) * f(size))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# iText's quirk: the height of a line is measured from the lower left to the upper right corner.
|
|
37
|
+
def auto_size_factor = f(metric(bounding_box[2], 1) - metric(bounding_box[1], 1))
|
|
38
|
+
|
|
39
|
+
def line_factor = f(metric(bounding_box[3], 1) - metric(bounding_box[1], 1))
|
|
40
|
+
|
|
41
|
+
def top(size) = metric(bounding_box[3], size)
|
|
42
|
+
|
|
43
|
+
def bottom(size) = metric(bounding_box[1], size)
|
|
44
|
+
|
|
45
|
+
def ascent(size) = metric(metrics[1], size)
|
|
46
|
+
|
|
47
|
+
def descent(size) = metric(metrics[2], size)
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# iText's BaseFont#getFontDescriptor: value * size / 1000 in floats.
|
|
52
|
+
def metric(value, size) = f(f(f(value) * f(size)) / 1000)
|
|
53
|
+
|
|
54
|
+
def f(value) = Float32.f(value)
|
|
55
|
+
|
|
56
|
+
def width(code)
|
|
57
|
+
@widths ||= {}
|
|
58
|
+
@widths[code] ||=
|
|
59
|
+
if (widths = deref(@dictionary[:Widths]))
|
|
60
|
+
first = @dictionary[:FirstChar].to_i
|
|
61
|
+
code.between?(first, first + widths.size - 1) ? deref(widths[code - first]).to_f : missing_width
|
|
62
|
+
elsif standard_font?
|
|
63
|
+
standard_metrics.widths[names[code]] || 0
|
|
64
|
+
else
|
|
65
|
+
missing_width
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def missing_width = deref(descriptor&.[](:MissingWidth)).to_f
|
|
70
|
+
|
|
71
|
+
def encoding = deref(@dictionary[:Encoding])
|
|
72
|
+
|
|
73
|
+
def base_encoding_name
|
|
74
|
+
encoding.is_a?(Hash) ? encoding[:BaseEncoding] : encoding
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def charset
|
|
78
|
+
return @charset if defined?(@charset)
|
|
79
|
+
|
|
80
|
+
@charset = CHARSETS[base_encoding_name]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Where Ruby's MacRoman table differs from Java's.
|
|
84
|
+
JAVA_MAC_ROMAN = { "¤" => nil, "Ω" => 0xBD, "€" => 0xDB, "Ω" => nil, "" => 0xF0 }.freeze
|
|
85
|
+
|
|
86
|
+
def charset_code(char)
|
|
87
|
+
return unless charset
|
|
88
|
+
return JAVA_MAC_ROMAN[char] if charset == Encoding::MacRoman && JAVA_MAC_ROMAN.key?(char)
|
|
89
|
+
|
|
90
|
+
char.encode(charset).ord
|
|
91
|
+
rescue EncodingError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Glyph names by code: the base encoding with the differences of the font applied.
|
|
96
|
+
def names
|
|
97
|
+
@names ||= base_names.dup.tap do |result|
|
|
98
|
+
difference_names.each { |code, name| result[code] = name }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def base_names
|
|
103
|
+
Glyphs.encoding(base_encoding_name) || builtin_names
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def builtin_names
|
|
107
|
+
standard_font? ? standard_metrics.encoding : Glyphs.encoding(:StandardEncoding)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def difference_names
|
|
111
|
+
@difference_names ||= begin
|
|
112
|
+
code = 0
|
|
113
|
+
Array(deref(encoding.is_a?(Hash) ? encoding[:Differences] : nil)).each_with_object({}) do |item, result|
|
|
114
|
+
item = deref(item)
|
|
115
|
+
if item.is_a?(Numeric)
|
|
116
|
+
code = item.to_i
|
|
117
|
+
else
|
|
118
|
+
result[code] = item
|
|
119
|
+
code += 1
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def differences
|
|
126
|
+
@differences ||= unicode_codes(difference_names)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def base_codes
|
|
130
|
+
@base_codes ||= unicode_codes(base_names.each_with_index.to_h { |name, code| [code, name] })
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unicode_codes(names_by_code)
|
|
134
|
+
dingbats = @dictionary[:BaseFont].to_s == "ZapfDingbats"
|
|
135
|
+
names_by_code.each_with_object({}) do |(code, name), result|
|
|
136
|
+
next unless name
|
|
137
|
+
|
|
138
|
+
char = Glyphs.unicode(name, dingbats: dingbats)
|
|
139
|
+
result[char] = code if char && !result.key?(char)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def bounding_box = metrics[0]
|
|
144
|
+
|
|
145
|
+
def metrics
|
|
146
|
+
@metrics ||=
|
|
147
|
+
if standard_font?
|
|
148
|
+
[standard_metrics.bounding_box, standard_metrics.ascender, standard_metrics.descender]
|
|
149
|
+
else
|
|
150
|
+
[deref(descriptor[:FontBBox]).map { |value| deref(value).to_f }, deref(descriptor[:Ascent]).to_f,
|
|
151
|
+
deref(descriptor[:Descent]).to_f]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def descriptor = deref(@dictionary[:FontDescriptor])
|
|
156
|
+
|
|
157
|
+
def standard_metrics = Glyphs.metrics(@dictionary[:BaseFont])
|
|
158
|
+
|
|
159
|
+
def standard_font?
|
|
160
|
+
@dictionary[:Subtype] == :Type1 && Glyphs.standard_font?(@dictionary[:BaseFont])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def deref(value) = @document.deref(value)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# The interactive form of a document: its terminal fields, in the order of the field tree.
|
|
5
|
+
class Form
|
|
6
|
+
attr_reader :document, :dictionary
|
|
7
|
+
|
|
8
|
+
def initialize(document)
|
|
9
|
+
@document = document
|
|
10
|
+
@dictionary = document.deref(document.catalog&.[](:AcroForm))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def present? = @dictionary.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
def [](key) = present? ? @document.deref(@dictionary[key]) : nil
|
|
16
|
+
|
|
17
|
+
def fields
|
|
18
|
+
return [] unless present?
|
|
19
|
+
|
|
20
|
+
@fields ||= Array(self[:Fields]).flat_map { |reference| terminal_fields(reference, nil, 0) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def field(name) = fields.find { |field| field.name == name }
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def terminal_fields(reference, parent, depth)
|
|
28
|
+
dictionary = @document.deref(reference)
|
|
29
|
+
return [] unless dictionary.is_a?(Hash) && depth < 32
|
|
30
|
+
|
|
31
|
+
field = FormField.new(@document, reference, dictionary, parent)
|
|
32
|
+
kids = field.field_kids
|
|
33
|
+
kids.empty? ? [field] : kids.flat_map { |kid| terminal_fields(kid, field, depth + 1) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A field of the form, with the attributes it inherits from its parents.
|
|
38
|
+
class FormField
|
|
39
|
+
INHERITABLE = %i[FT Ff V DV DA Q MaxLen].freeze
|
|
40
|
+
PUSH_BUTTON = 1 << 16
|
|
41
|
+
RADIO = 1 << 15
|
|
42
|
+
COMBO = 1 << 17
|
|
43
|
+
|
|
44
|
+
attr_reader :document, :reference, :dictionary, :parent
|
|
45
|
+
|
|
46
|
+
def initialize(document, reference, dictionary, parent)
|
|
47
|
+
@document = document
|
|
48
|
+
@reference = reference
|
|
49
|
+
@dictionary = dictionary
|
|
50
|
+
@parent = parent
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def [](key)
|
|
54
|
+
value = @document.deref(@dictionary[key])
|
|
55
|
+
return value unless value.nil? && INHERITABLE.include?(key) && @parent
|
|
56
|
+
|
|
57
|
+
@parent[key]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def []=(key, value)
|
|
61
|
+
@dictionary[key] = value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def key?(key) = @dictionary.key?(key)
|
|
65
|
+
|
|
66
|
+
# A value of the field or of the nearest parent that has it.
|
|
67
|
+
def lookup(key)
|
|
68
|
+
@dictionary.key?(key) ? @document.deref(@dictionary[key]) : @parent&.lookup(key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def name
|
|
72
|
+
own = @dictionary[:T] && PDF::Text.decode(@document.deref(@dictionary[:T]))
|
|
73
|
+
[@parent&.name, own].compact.join(".")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def field_type = self[:FT]
|
|
77
|
+
|
|
78
|
+
def flags = self[:Ff].to_i
|
|
79
|
+
|
|
80
|
+
def concrete_type
|
|
81
|
+
case field_type
|
|
82
|
+
when :Btn then button_type
|
|
83
|
+
when :Ch then flags.nobits?(COMBO) ? :list_box : :combo_box
|
|
84
|
+
when :Tx then :text_field
|
|
85
|
+
else field_type
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Kids with a name are fields, the other kids are widgets.
|
|
90
|
+
def field_kids
|
|
91
|
+
kids.select { |kid| @document.deref(kid).is_a?(Hash) && @document.deref(kid).key?(:T) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The field itself when its widget is merged into it.
|
|
95
|
+
def widgets
|
|
96
|
+
widget_kids = kids.reject { |kid| @document.deref(kid).is_a?(Hash) && @document.deref(kid).key?(:T) }
|
|
97
|
+
references = kids.empty? ? [@reference] : widget_kids
|
|
98
|
+
references.filter_map do |reference|
|
|
99
|
+
dictionary = @document.deref(reference)
|
|
100
|
+
Widget.new(@document, reference, dictionary) if dictionary.is_a?(Hash)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def kids = Array(@document.deref(@dictionary[:Kids]))
|
|
107
|
+
|
|
108
|
+
def button_type
|
|
109
|
+
return :push_button if flags.anybits?(PUSH_BUTTON)
|
|
110
|
+
|
|
111
|
+
flags.nobits?(RADIO) ? :check_box : :radio_button
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A widget annotation of a field.
|
|
116
|
+
class Widget
|
|
117
|
+
attr_reader :reference, :dictionary
|
|
118
|
+
|
|
119
|
+
def initialize(document, reference, dictionary)
|
|
120
|
+
@document = document
|
|
121
|
+
@reference = reference
|
|
122
|
+
@dictionary = dictionary
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def [](key) = @document.deref(@dictionary[key])
|
|
126
|
+
|
|
127
|
+
# A value of the widget's entries, with any reference inside it resolved.
|
|
128
|
+
def resolve(value)
|
|
129
|
+
value = @document.deref(value)
|
|
130
|
+
case value
|
|
131
|
+
when Array then value.map { |item| resolve(item) }
|
|
132
|
+
when Hash then value.transform_values { |item| resolve(item) }
|
|
133
|
+
else value
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def []=(key, value)
|
|
138
|
+
@dictionary[key] = value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def key?(key) = @dictionary.key?(key)
|
|
142
|
+
|
|
143
|
+
# The normalized rectangle: [left, bottom, right, top].
|
|
144
|
+
def rect
|
|
145
|
+
left, bottom, right, top = Array(self[:Rect]).map { |value| @document.deref(value).to_f }
|
|
146
|
+
[[left, right].min, [bottom, top].min, [left, right].max, [bottom, top].max]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normal_appearances
|
|
150
|
+
appearances = self[:AP]
|
|
151
|
+
appearances.is_a?(Hash) ? @document.deref(appearances[:N]) : nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The names of the states the widget has a normal, down and rollover appearance for.
|
|
155
|
+
def states_by_kind
|
|
156
|
+
appearances = self[:AP]
|
|
157
|
+
return [] unless appearances.is_a?(Hash)
|
|
158
|
+
|
|
159
|
+
%i[N D R].map do |key|
|
|
160
|
+
states = @document.deref(appearances[key])
|
|
161
|
+
states.is_a?(Hash) ? states.keys : []
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fillform
|
|
4
|
+
# Glyph names, the metrics of the 14 standard fonts, and the simple font encodings.
|
|
5
|
+
# The data files are Adobe's: the core 14 AFM files and the Adobe Glyph List, see data/.
|
|
6
|
+
module Glyphs
|
|
7
|
+
DATA = File.expand_path("../../data", __dir__)
|
|
8
|
+
STANDARD_FONTS = %w[Courier Courier-Bold Courier-BoldOblique Courier-Oblique Helvetica Helvetica-Bold
|
|
9
|
+
Helvetica-BoldOblique Helvetica-Oblique Symbol Times-Bold Times-BoldItalic Times-Italic
|
|
10
|
+
Times-Roman ZapfDingbats].freeze
|
|
11
|
+
|
|
12
|
+
Metrics = Struct.new(:widths, :encoding, :bounding_box, :ascender, :descender)
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def standard_font?(name) = STANDARD_FONTS.include?(name.to_s)
|
|
16
|
+
|
|
17
|
+
def metrics(font_name)
|
|
18
|
+
@metrics ||= {}
|
|
19
|
+
@metrics[font_name.to_s] ||= read_afm(File.join(DATA, "afm", "#{font_name}.afm"))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unicode(name, dingbats: false)
|
|
23
|
+
name = name.to_s
|
|
24
|
+
(dingbats && zapf_dingbats[name]) || glyph_list[name] || algorithmic(name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Glyph names by code of the encodings a font can name.
|
|
28
|
+
def encoding(name)
|
|
29
|
+
@encodings ||= {}
|
|
30
|
+
@encodings[name] ||=
|
|
31
|
+
case name
|
|
32
|
+
when :StandardEncoding then metrics("Helvetica").encoding
|
|
33
|
+
when :WinAnsiEncoding then charset_encoding(Encoding::Windows_1252, 0xA0 => :space, 0xAD => :hyphen)
|
|
34
|
+
when :MacRomanEncoding then charset_encoding(Encoding::MacRoman, 0xCA => :space)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_afm(path)
|
|
41
|
+
widths = {}
|
|
42
|
+
encoding = Array.new(256)
|
|
43
|
+
values = {}
|
|
44
|
+
File.foreach(path, encoding: Encoding::ISO_8859_1) do |line|
|
|
45
|
+
if line.start_with?("C ")
|
|
46
|
+
code = line[/\AC (-?\d+)/, 1].to_i
|
|
47
|
+
name = line[/; N (\S+)/, 1].to_sym
|
|
48
|
+
widths[name] = line[/WX (\S+)/, 1].to_f
|
|
49
|
+
encoding[code] = name if code.between?(0, 255)
|
|
50
|
+
elsif (match = line.match(/\A(FontBBox|Ascender|Descender) (.+)/))
|
|
51
|
+
values[match[1]] = match[2].split.map(&:to_f)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
box = values["FontBBox"]
|
|
55
|
+
Metrics.new(widths, encoding, box, values.dig("Ascender", 0) || box[3], values.dig("Descender", 0) || box[1])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def charset_encoding(charset, overrides)
|
|
59
|
+
names = metrics("Helvetica").widths.keys.to_h { |name| [unicode(name), name] }
|
|
60
|
+
Array.new(256) do |code|
|
|
61
|
+
overrides.fetch(code) do
|
|
62
|
+
names[code.chr.force_encoding(charset).encode(Encoding::UTF_8)]
|
|
63
|
+
rescue EncodingError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def glyph_list
|
|
70
|
+
@glyph_list ||= read_list("glyphlist.txt")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def zapf_dingbats
|
|
74
|
+
@zapf_dingbats ||= read_list("zapfdingbats.txt")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_list(file)
|
|
78
|
+
File.foreach(File.join(DATA, "agl", file)).each_with_object({}) do |line, list|
|
|
79
|
+
next if line.start_with?("#")
|
|
80
|
+
|
|
81
|
+
name, codes = line.chomp.split(";")
|
|
82
|
+
list[name] = codes.split.map(&:hex).pack("U*") if codes
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# uniXXXX and uXXXX[XX] names, and names with a suffix like a.sc.
|
|
87
|
+
def algorithmic(name)
|
|
88
|
+
base = name.split(".").first.to_s
|
|
89
|
+
if (hex = base[/\Auni((?:\h{4})+)\z/, 1])
|
|
90
|
+
hex.scan(/\h{4}/).map(&:hex).pack("U*")
|
|
91
|
+
elsif (hex = base[/\Au(\h{4,6})\z/, 1])
|
|
92
|
+
[hex.hex].pack("U")
|
|
93
|
+
elsif base != name
|
|
94
|
+
glyph_list[base]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|