# -*- encoding: utf-8; frozen_string_literal: true -*-
#
#--
# This file is part of HexaPDF.
#
# HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
# Copyright (C) 2014-2024 Thomas Leitner
#
# HexaPDF is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License version 3 as
# published by the Free Software Foundation with the addition of the
# following permission added to Section 15 as permitted in Section 7(a):
# FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
# THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
# INFRINGEMENT OF THIRD PARTY RIGHTS.
#
# HexaPDF is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
# License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with HexaPDF. If not, see .
#
# The interactive user interfaces in modified source and object code
# versions of HexaPDF must display Appropriate Legal Notices, as required
# under Section 5 of the GNU Affero General Public License version 3.
#
# In accordance with Section 7(b) of the GNU Affero General Public
# License, a covered work must retain the producer line in every PDF that
# is created or manipulated using HexaPDF.
#
# If the GNU Affero General Public License doesn't fit your need,
# commercial licenses are available at .
#++
require 'hexapdf/dictionary'
require 'hexapdf/stream'
require 'hexapdf/type/acro_form/field'
require 'hexapdf/utils/bit_field'
module HexaPDF
module Type
module AcroForm
# Represents the PDF's interactive form dictionary. It is linked from the catalog dictionary
# via the /AcroForm entry.
#
# == Overview
#
# An interactive form consists of fields which can be structured hierarchically and shown on
# pages by using Annotations::Widget annotations. This means one field can have zero, one or
# more visual representations on one or more pages. The fields at the bottom of the hierarchy
# which have no parent are called "root fields" and are stored in /Fields.
#
# Each field in a form has a certain type which determines how it should be displayed and what
# a user can do with it. The most common type is "text field" which allows the user to enter
# one or more lines of text. There are also check boxes, radio buttons, list boxes and combo
# boxes.
#
# == Visual Appearance
#
# The visual appearance of a field is normally provided by the application creating the PDF.
# This is done by generating the so called appearances for all widgets of a field. However, it
# is also possible to instruct the PDF reader application to generate the appearances on the
# fly using the /NeedAppearances key, see #need_appearances!.
#
# HexaPDF uses the configuration option +acro_form.create_appearance_streams+ to determine
# whether appearances should automatically be generated.
#
# See: PDF2.0 s12.7.3, Field, HexaPDF::Type::Annotations::Widget
class Form < Dictionary
extend Utils::BitField
define_type :XXAcroForm
define_field :Fields, type: PDFArray, required: true, default: [], version: '1.2'
define_field :NeedAppearances, type: Boolean, default: false
define_field :SigFlags, type: Integer, version: '1.3'
define_field :CO, type: PDFArray, version: '1.3'
define_field :DR, type: :XXResources
define_field :DA, type: String
define_field :XFA, type: [Stream, PDFArray], version: '1.5'
bit_field(:signature_flags, {signatures_exist: 0, append_only: 1},
lister: "signature_flags", getter: "signature_flag?", setter: "signature_flag",
unsetter: "signature_unflag", value_getter: "self[:SigFlags]",
value_setter: "self[:SigFlags]")
# Returns the PDFArray containing the root fields.
def root_fields
self[:Fields] ||= document.wrap([])
end
# Returns an array with all root fields that were found in the PDF document.
def find_root_fields
result = []
document.pages.each do |page|
page.each_annotation do |annot|
if !annot.key?(:Parent) && annot.key?(:FT)
result << Field.wrap(document, annot)
elsif annot.key?(:Parent)
field = annot[:Parent]
field = field[:Parent] while field[:Parent]
result << Field.wrap(document, field)
end
end
end
result
end
# Finds all root fields and sets /Fields appropriately.
#
# See: #find_root_fields
def find_root_fields!
self[:Fields] = find_root_fields
end
# :call-seq:
# acroform.each_field(terminal_only: true) {|field| block} -> acroform
# acroform.each_field(terminal_only: true) -> Enumerator
#
# Yields all terminal fields or all fields, depending on the +terminal_only+ argument.
def each_field(terminal_only: true)
return to_enum(__method__, terminal_only: terminal_only) unless block_given?
process_field_array = lambda do |array|
array.each_with_index do |field, index|
next if field.nil?
unless field.respond_to?(:type) && field.type == :XXAcroFormField
array[index] = field = Field.wrap(document, field)
end
if field.terminal_field?
yield(field)
else
yield(field) unless terminal_only
process_field_array.call(field[:Kids])
end
end
end
process_field_array.call(root_fields)
self
end
# Returns the field with the given +name+ or +nil+ if no such field exists.
def field_by_name(name)
fields = root_fields
field = nil
name.split('.').each do |part|
field = nil
fields&.each do |f|
f = Field.wrap(document, f)
next unless f[:T] == part
field = f
fields = field[:Kids] unless field.terminal_field?
break
end
end
field
end
# Creates an untyped namespace field for creating hierarchies.
#
# Example:
#
# form.create_namespace_field('text')
# form.create_text_field('text.a1')
def create_namespace_field(name)
create_field(name)
end
# Creates a new text field with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field:
#
# +font+::
# The font that should be used for the text of the field. If +font_size+, +font_options+
# or +font_color+ is specified but +font+ isn't, the font Helvetica is used.
#
# If no font is set on the text field, the default font properties of the AcroForm form
# are used. Note that field specific or form specific font properties have to be set.
# Otherwise there will be an error when trying to generate a visual representation of
# the field value.
#
# +font_options+::
# A hash with font options like :variant that should be used.
#
# +font_size+::
# The font size that should be used. If +font+, +font_options+ or +font_color+ is
# specified but +font_size+ isn't, font size defaults to 0 (= auto-sizing).
#
# +font_color+::
# The font color that should be used. If +font+, +font_options+ or +font_size+ is
# specified but +font_color+ isn't, font color defaults to 0 (i.e. black).
#
# +align+::
# The alignment of the text, either :left, :center or :right.
def create_text_field(name, font: nil, font_options: nil, font_size: nil, font_color: nil,
align: nil)
create_field(name, :Tx) do |field|
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
end
end
# Creates a new multiline text field with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field, see
# #create_text_field for details.
def create_multiline_text_field(name, font: nil, font_options: nil, font_size: nil,
font_color: nil, align: nil)
create_field(name, :Tx) do |field|
field.initialize_as_multiline_text_field
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
end
end
# Creates a new comb text field with the given name and adds it to the form.
#
# The +max_chars+ argument defines the maximum number of characters the comb text field can
# accommodate.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field, see
# #create_text_field for details.
def create_comb_text_field(name, max_chars:, font: nil, font_options: nil, font_size: nil,
font_color: nil, align: nil)
create_field(name, :Tx) do |field|
field.initialize_as_comb_text_field
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
field[:MaxLen] = max_chars
end
end
# Creates a new file select field with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field, see
# #create_text_field for details.
def create_file_select_field(name, font: nil, font_options: nil, font_size: nil,
font_color: nil, align: nil)
create_field(name, :Tx) do |field|
field.initialize_as_file_select_field
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
end
end
# Creates a new password field with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field, see
# #create_text_field for details.
def create_password_field(name, font: nil, font_options: nil, font_size: nil,
font_color: nil, align: nil)
create_field(name, :Tx) do |field|
field.initialize_as_password_field
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
end
end
# Creates a new check box with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# Before a field value other than +false+ can be assigned to the check box, a widget needs
# to be created.
def create_check_box(name)
create_field(name, :Btn, &:initialize_as_check_box)
end
# Creates a radio button with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# Before a field value other than +nil+ can be assigned to the radio button, at least one
# widget needs to be created.
def create_radio_button(name)
create_field(name, :Btn, &:initialize_as_radio_button)
end
# Creates a combo box with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field:
#
# +option_items+::
# Specifies the values of the list box.
#
# +editable+::
# If set to +true+, the combo box allows entering an arbitrary value in addition to
# selecting one of the provided option items.
#
# +font+, +font_options+, +font_size+ and +align+::
# See #create_text_field
def create_combo_box(name, option_items: nil, editable: nil, font: nil,
font_options: nil, font_size: nil, font_color: nil, align: nil)
create_field(name, :Ch) do |field|
field.initialize_as_combo_box
field.option_items = option_items if option_items
field.flag(:edit) if editable
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
end
end
# Creates a list box with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
#
# The optional keyword arguments allow setting often used properties of the field:
#
# +option_items+::
# Specifies the values of the list box.
#
# +multi_select+::
# If set to +true+, the list box allows selecting multiple items instead of only one.
#
# +font+, +font_options+, +font_size+ and +align+::
# See #create_text_field.
def create_list_box(name, option_items: nil, multi_select: nil, font: nil,
font_options: nil, font_size: nil, font_color: nil, align: nil)
create_field(name, :Ch) do |field|
field.initialize_as_list_box
field.option_items = option_items if option_items
field.flag(:multi_select) if multi_select
apply_variable_text_properties(field, font: font, font_options: font_options,
font_size: font_size, font_color: font_color, align: align)
end
end
# Creates a signature field with the given name and adds it to the form.
#
# The +name+ may contain dots to signify a field hierarchy. If the parent fields don't
# already exist, they are created as pure namespace fields (see #create_namespace_field). If
# the +name+ doesn't contain dots, a top-level field is created.
def create_signature_field(name)
create_field(name, :Sig)
end
# :call-seq:
# form.delete_field(name)
# form.delete_field(field)
#
# Deletes the field specified by the given name or via the given field object.
#
# If the field is a signature field, the associated signature dictionary is also deleted.
def delete_field(name_or_field)
field = (name_or_field.kind_of?(String) ? field_by_name(name_or_field) : name_or_field)
document.delete(field[:V]) if field.field_type == :Sig
to_delete = field.each_widget(direct_only: false).to_a
document.pages.each do |page|
next unless page.key?(:Annots)
page_annots = page[:Annots].to_a - to_delete
page[:Annots].value.replace(page_annots)
end
if field[:Parent]
field[:Parent][:Kids].delete(field)
else
self[:Fields].delete(field)
end
to_delete.each {|widget| document.delete(widget) }
document.delete(field)
end
# Fills form fields with the values from the given +data+ hash.
#
# The keys of the +data+ hash need to be full field names and the values are the respective
# values, usually in string form. It is possible to specify only some of the fields of the
# form.
#
# What kind of values are supported for a field depends on the field type:
#
# * For fields containing text (single/multiline/comb text fields, file select fields, combo
# boxes and list boxes) the value needs to be a string and it is assigned as is.
#
# * For check boxes, the values "y"/"yes"/"t"/"true" are handled as assigning +true+ to the
# field, the values "n"/"no"/"f"/"false" are handled as assigning +false+ to the field,
# and every other string value is assigned as is. See ButtonField#field_value= for
# details.
#
# * For radio buttons the value needs to be a String or a Symbol representing the name of
# the radio button widget to select.
def fill(data)
data.each do |field_name, value|
field = field_by_name(field_name)
raise HexaPDF::Error, "AcroForm field named '#{field_name}' not found" unless field
case field.concrete_field_type
when :single_line_text_field, :multiline_text_field, :comb_text_field, :file_select_field,
:combo_box, :list_box, :editable_combo_box, :radio_button
field.field_value = value
when :check_box
field.field_value = case value
when /\A(?:y(es)?|t(rue)?)\z/ then true
when /\A(?:n(o)?|f(alse)?)\z/ then false
else value
end
else
raise HexaPDF::Error, "AcroForm field type #{field.concrete_field_type} not yet supported"
end
end
end
# Returns the dictionary containing the default resources for form field appearance streams.
def default_resources
self[:DR] ||= document.wrap({ProcSet: [:PDF, :Text, :ImageB, :ImageC, :ImageI]},
type: :XXResources)
end
# Sets the global default appearance string using the provided values or the default values
# which provide a sane default.
#
# See VariableTextField::create_appearance_string for information on the arguments.
def set_default_appearance_string(font: 'Helvetica', font_options: {}, font_size: 0,
font_color: 0)
self[:DA] = VariableTextField.create_appearance_string(document, font: font,
font_options: font_options,
font_size: font_size,
font_color: font_color)
end
# Sets the /NeedAppearances field to +true+.
#
# This will make PDF reader applications generate appropriate appearance streams based on
# the information stored in the fields and associated widgets.
def need_appearances!
self[:NeedAppearances] = true
end
# Creates the appearances for all widgets of all terminal fields if they don't exist.
#
# If +force+ is +true+, new appearances are created even if there are existing ones.
def create_appearances(force: false)
each_field do |field|
field.create_appearances(force: force) if field.respond_to?(:create_appearances)
end
end
# Flattens the whole interactive form or only the given fields, and returns the fields that
# couldn't be flattened.
#
# Flattening means making the appearance streams of the field widgets part of the respective
# page's content stream and removing the fields themselves.
#
# If the whole interactive form is flattened, the form object itself is also removed if all
# fields were flattened.
#
# The +create_appearances+ argument controls whether missing appearances should
# automatically be created.
#
# See: HexaPDF::Type::Page#flatten_annotations
def flatten(fields: nil, create_appearances: true)
remove_form = fields.nil?
fields ||= each_field.to_a
if create_appearances
fields.each {|field| field.create_appearances if field.respond_to?(:create_appearances) }
end
not_flattened = fields.map {|field| field.each_widget(direct_only: true).to_a }.flatten
document.pages.each {|page| not_flattened = page.flatten_annotations(not_flattened) }
not_flattened.map!(&:form_field)
fields -= not_flattened
fields.each do |field|
(field[:Parent]&.[](:Kids) || self[:Fields]).delete(field)
document.delete(field)
end
if remove_form && not_flattened.empty?
document.catalog.delete(:AcroForm)
document.delete(self)
end
not_flattened
end
# Recalculates all form fields that have a calculate action applied (which are all fields
# listed in the /CO entry).
#
# If HexaPDF doesn't support a calculation method or an error occurs during calculation, the
# field value is not updated.
#
# Note that calculations are *not* done automatically when a form field's value changes
# since it would lead to possibly many calls to this actions. So first fill in all field
# values and then call this method.
#
# See: JavaScriptActions
def recalculate_fields
self[:CO]&.each do |field|
field = Field.wrap(document, field)
next unless field && (calculation_action = field[:AA]&.[](:C))
result = JavaScriptActions.calculate(self, calculation_action)
field.form_field.field_value = result if result
end
end
private
# Creates a new field with the full name +name+ and the optional field type +type+.
def create_field(name, type = nil)
parent_name, _, name = name.rpartition('.')
parent_field = parent_name.empty? ? nil : field_by_name(parent_name)
if !parent_name.empty? && !parent_field
parent_field = create_namespace_field(parent_name)
end
field = if type
document.add({FT: type, T: name, Parent: parent_field},
type: :XXAcroFormField, subtype: type)
else
document.add({T: name, Parent: parent_field}, type: :XXAcroFormField)
end
if parent_field
(parent_field[:Kids] ||= []) << field
else
(self[:Fields] ||= []) << field
end
yield(field) if block_given?
field
end
# Applies the given variable field properties to the field.
def apply_variable_text_properties(field, font: nil, font_options: nil, font_size: nil,
font_color: nil, align: nil)
if font || font_options || font_size || font_color
field.set_default_appearance_string(font: font || 'Helvetica',
font_options: font_options || {},
font_size: font_size || 0,
font_color: font_color || 0)
end
field.text_alignment(align) if align
end
def perform_validation # :nodoc:
super
seen = {} # used for combining field
validate_array = lambda do |parent, container|
container.reject! do |field|
if !field.kind_of?(HexaPDF::Object) || !field.kind_of?(HexaPDF::Dictionary) || field.null?
yield("Invalid object in AcroForm field hierarchy", true)
next true
end
next false unless field.key?(:T) # Skip widgets
field = Field.wrap(document, field)
reject = false
if field[:Parent] != parent
yield("Parent entry of field (#{field.oid},#{field.gen}) invalid", true)
if field[:Parent].nil?
root_fields << field
reject = true
else
field[:Parent] = parent
end
end
# Combine fields with same name
name = field.full_field_name
if (other_field = seen[name])
kids = other_field[:Kids] ||= []
kids << other_field.send(:extract_widget) if other_field.embedded_widget?
widgets = field.embedded_widget? ? [field.send(:extract_widget)] : field.each_widget.to_a
widgets.each do |widget|
widget[:Parent] = other_field
kids << widget
end
reject = true
elsif !reject
seen[name] = field
end
validate_array.call(field, field[:Kids]) if field.key?(:Kids)
reject
end
end
validate_array.call(nil, root_fields)
if (da = self[:DA])
unless self[:DR]
yield("When the field /DA is present, the field /DR must also be present")
return
end
font_name, = VariableTextField.parse_appearance_string(da)
if font_name && !(self[:DR][:Font] && self[:DR][:Font][font_name])
yield("The font specified in /DA is not in the /DR resource dictionary")
end
else
set_default_appearance_string
end
create_appearances if document.config['acro_form.create_appearances']
end
end
end
end
end