mirror of https://github.com/docusealco/docuseal
				
				
				
			
			You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							282 lines
						
					
					
						
							8.7 KiB
						
					
					
				
			
		
		
	
	
							282 lines
						
					
					
						
							8.7 KiB
						
					
					
				| # frozen_string_literal: true
 | |
| 
 | |
| module Templates
 | |
|   module FindAcroFields
 | |
|     PDF_CONTENT_TYPE = 'application/pdf'
 | |
| 
 | |
|     FIELD_NAME_REGEXP = /\A(?=.*\p{L})[\p{L}\d\s-]+\z/
 | |
|     SKIP_FIELD_DESCRIPTION = %w[undefined].freeze
 | |
|     SELECT_PLACEHOLDER_REGEXP = /\b(
 | |
|       Select      |
 | |
|       Choose      |
 | |
|       Wählen      |
 | |
|       Auswählen   |
 | |
|       Sélectionner|
 | |
|       Choisir     |
 | |
|       Seleccionar |
 | |
|       Elegir      |
 | |
|       Seleziona   |
 | |
|       Scegliere   |
 | |
|       Selecionar  |
 | |
|       Escolher
 | |
|     )\b/ix
 | |
| 
 | |
|     DATE_FORMAT_REGEXP = %r{[myd]{2,4}[-\\/\s.][myd]{2,4}[-\\/\s.][myd]{2,4}}i
 | |
| 
 | |
|     FIELD_ALIGNMENT = {
 | |
|       0 => 'left',
 | |
|       1 => 'center',
 | |
|       2 => 'right'
 | |
|     }.freeze
 | |
| 
 | |
|     module_function
 | |
| 
 | |
|     # rubocop:disable Metrics
 | |
|     def call(pdf, attachment, data)
 | |
|       return [] if pdf.acro_form.blank? && data.exclude?('/Form')
 | |
| 
 | |
|       fields, annots_index = build_fields_with_pages(pdf)
 | |
| 
 | |
|       fields.filter_map do |field|
 | |
|         areas = Array.wrap(field[:Kids] || field).filter_map do |child_field|
 | |
|           page = annots_index[child_field.hash]
 | |
| 
 | |
|           next unless page
 | |
| 
 | |
|           media_box = page[:CropBox] || page[:MediaBox]
 | |
|           crop_box = page[:CropBox] || media_box
 | |
| 
 | |
|           media_box_start = [media_box[0], media_box[1]]
 | |
|           crop_shift = [crop_box[0] - media_box[0], crop_box[1] - media_box[1]]
 | |
| 
 | |
|           next unless child_field[:Rect]
 | |
| 
 | |
|           x0, y0, x1, y1 = child_field[:Rect]
 | |
| 
 | |
|           x0, y0 = correct_coordinates(x0, y0, crop_shift, media_box_start)
 | |
|           x1, y1 = correct_coordinates(x1, y1, crop_shift, media_box_start)
 | |
| 
 | |
|           page_width = media_box[2] - media_box[0]
 | |
|           page_height = media_box[3] - media_box[1]
 | |
| 
 | |
|           x = x0
 | |
|           y = y0
 | |
|           w = x1 - x0
 | |
|           h = y1 - y0
 | |
| 
 | |
|           transformed_y = page_height - y - h
 | |
| 
 | |
|           attrs = {
 | |
|             page: page.index,
 | |
|             x: x / page_width.to_f,
 | |
|             y: transformed_y / page_height.to_f,
 | |
|             w: w / page_width.to_f,
 | |
|             h: h / page_height.to_f,
 | |
|             attachment_uuid: attachment.uuid
 | |
|           }
 | |
| 
 | |
|           next if attrs[:w].zero? || attrs[:h].zero?
 | |
| 
 | |
|           if child_field[:MaxLen] && child_field.try(:concrete_field_type) == :comb_text_field
 | |
|             attrs[:cell_w] = w / page_width / child_field[:MaxLen].to_f
 | |
|           end
 | |
| 
 | |
|           attrs
 | |
|         end
 | |
| 
 | |
|         next if areas.blank?
 | |
| 
 | |
|         field_properties = build_field_properties(field)
 | |
| 
 | |
|         next if field_properties.blank?
 | |
|         next if field_properties[:default_value].present?
 | |
| 
 | |
|         if field_properties[:type].in?(%w[radio multiple])
 | |
|           if areas.size != field_properties[:options].size
 | |
|             field_properties[:options] = build_options(Array.new(areas.size, ''))
 | |
|           end
 | |
| 
 | |
|           areas.each_with_index do |area, index|
 | |
|             area[:option_uuid] = field_properties[:options][index][:uuid]
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         {
 | |
|           uuid: SecureRandom.uuid,
 | |
|           required: field.flags.include?(:required),
 | |
|           preferences: {},
 | |
|           areas:,
 | |
|           **field_properties
 | |
|         }
 | |
|       end
 | |
|     rescue StandardError => e
 | |
|       raise if Rails.env.local?
 | |
| 
 | |
|       Rollbar.error(e) if defined?(Rollbar)
 | |
| 
 | |
|       []
 | |
|     end
 | |
| 
 | |
|     def correct_coordinates(x_coord, y_coord, shift, media_box_start)
 | |
|       corrected_x = x_coord + shift[0] - media_box_start[0]
 | |
|       corrected_y = y_coord + shift[1] - media_box_start[1]
 | |
| 
 | |
|       [corrected_x, corrected_y]
 | |
|     end
 | |
| 
 | |
|     def build_field_properties(field)
 | |
|       field_name = field.full_field_name if field.full_field_name.to_s.match?(FIELD_NAME_REGEXP)
 | |
| 
 | |
|       field_name = field_name&.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
 | |
| 
 | |
|       attrs = { name: field_name.to_s }
 | |
|       attrs[:description] = field[:TU] if field[:TU].present? &&
 | |
|                                           field[:TU] != field.full_field_name &&
 | |
|                                           !field[:TU].in?(SKIP_FIELD_DESCRIPTION)
 | |
| 
 | |
|       if field[:Q].present? && field.field_type == :Tx
 | |
|         attrs[:preferences] ||= {}
 | |
|         attrs[:preferences][:align] = FIELD_ALIGNMENT.fetch(field[:Q], 'left')
 | |
|       end
 | |
| 
 | |
|       if field.field_type == :Btn && field.concrete_field_type == :radio_button && field[:Opt].present?
 | |
|         selected_option_index = (field.allowed_values || []).find_index(field.field_value)
 | |
|         selected_option = field[:Opt][selected_option_index] if selected_option_index
 | |
| 
 | |
|         {
 | |
|           **attrs,
 | |
|           type: 'radio',
 | |
|           options: build_options(field[:Opt], 'radio'),
 | |
|           default_value: selected_option
 | |
|         }
 | |
|       elsif field.field_type == :Btn && %i[check_box radio_button].include?(field.concrete_field_type) &&
 | |
|             field[:Kids].present? && field[:Kids].size > 1 && field.allowed_values.size > 1
 | |
|         selected_option = (field.allowed_values || []).find { |v| v == field.field_value }
 | |
| 
 | |
|         return {} if field.allowed_values.include?(:BBox)
 | |
| 
 | |
|         {
 | |
|           **attrs,
 | |
|           type: 'radio',
 | |
|           options: build_options(field.allowed_values, 'radio'),
 | |
|           default_value: selected_option
 | |
|         }
 | |
|       elsif field.field_type == :Btn && %i[check_box radio_button].include?(field.concrete_field_type)
 | |
|         {
 | |
|           **attrs,
 | |
|           type: 'checkbox',
 | |
|           default_value: field.field_value.present?
 | |
|         }
 | |
|       elsif field.field_type == :Ch &&
 | |
|             %i[combo_box editable_combo_box].include?(field.concrete_field_type) && field[:Opt].present?
 | |
|         {
 | |
|           **attrs,
 | |
|           type: 'select',
 | |
|           options: build_options(field[:Opt], 'select'),
 | |
|           default_value: field.field_value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : field.field_value
 | |
|         }
 | |
|       elsif field.field_type == :Ch && field.concrete_field_type == :multi_select && field[:Opt].present?
 | |
|         {
 | |
|           **attrs,
 | |
|           type: 'multiple',
 | |
|           options: build_options(field[:Opt], 'multiple'),
 | |
|           default_value: field.field_value
 | |
|         }
 | |
|       elsif field.field_type == :Tx && field.concrete_field_type == :comb_text_field
 | |
|         {
 | |
|           **attrs,
 | |
|           type: 'cells',
 | |
|           default_value: field.field_value
 | |
|         }
 | |
|       elsif field.field_type == :Tx
 | |
|         if field[:AA] && ((field[:AA][:F] && field[:AA][:F][:JS].include?('AFDate_')) ||
 | |
|             (field[:AA][:K] && field[:AA][:K][:JS].include?('AFDate_')))
 | |
|           if (format = field[:AA][:F][:JS][DATE_FORMAT_REGEXP])
 | |
|             attrs[:preferences] ||= {}
 | |
|             attrs[:preferences][:format] = format.upcase
 | |
|           end
 | |
| 
 | |
|           {
 | |
|             **attrs,
 | |
|             type: 'date',
 | |
|             default_value: field.field_value
 | |
|           }
 | |
|         else
 | |
|           {
 | |
|             **attrs,
 | |
|             type: 'text',
 | |
|             default_value: field.field_value
 | |
|           }
 | |
|         end
 | |
|       elsif field.field_type == :Sig
 | |
|         {
 | |
|           **attrs,
 | |
|           type: field.try(:field_name).to_s.downcase.include?('initials') ? 'initials' : 'signature'
 | |
|         }
 | |
|       else
 | |
|         {}
 | |
|       end.compact
 | |
|     end
 | |
| 
 | |
|     def build_options(values, type = nil)
 | |
|       is_skip_single_value = type.in?(%w[radio multiple]) && values.uniq.size == 1
 | |
| 
 | |
|       values.filter_map do |option|
 | |
|         is_option_number = option.is_a?(Symbol) && option.to_s.match?(/\A\d+\z/)
 | |
| 
 | |
|         option = option[1] if option.is_a?(Array) && option.size == 2
 | |
|         option = option.encode('utf-8', invalid: :replace, undef: :replace, replace: '') if option.is_a?(String)
 | |
| 
 | |
|         next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP)
 | |
| 
 | |
|         {
 | |
|           uuid: SecureRandom.uuid,
 | |
|           value: is_option_number || is_skip_single_value ? '' : option
 | |
|         }
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     def build_fields_with_pages(pdf)
 | |
|       fields_index = {}
 | |
|       annots_index = {}
 | |
| 
 | |
|       pdf.pages.each do |page|
 | |
|         page.each_annotation do |annot|
 | |
|           annots_index[annot.hash] = page
 | |
| 
 | |
|           if !annot.key?(:Parent) && annot.key?(:FT)
 | |
|             fields_index[annot.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, annot)
 | |
|           elsif annot.key?(:Parent)
 | |
|             field = annot[:Parent]
 | |
|             field = field[:Parent] while field[:Parent]
 | |
| 
 | |
|             annots_index[field.hash] ||= page
 | |
|             fields_index[field.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
 | |
|           end
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       [process_fields_array(pdf, fields_index.values), annots_index]
 | |
|     end
 | |
| 
 | |
|     def process_fields_array(pdf, array, acc = [])
 | |
|       array.each_with_index do |field, index|
 | |
|         next if field.nil?
 | |
| 
 | |
|         unless field.respond_to?(:type) && field.type == :XXAcroFormField
 | |
|           array[index] = field = HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
 | |
|         end
 | |
| 
 | |
|         if field.terminal_field?
 | |
|           acc << field
 | |
|         else
 | |
|           process_fields_array(pdf, field[:Kids], acc)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       acc
 | |
|     end
 | |
|     # rubocop:enable Metrics
 | |
|   end
 | |
| end
 |