# -*- 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