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.
		
		
		
		
		
			
		
			
				
					
					
						
							881 lines
						
					
					
						
							32 KiB
						
					
					
				
			
		
		
	
	
							881 lines
						
					
					
						
							32 KiB
						
					
					
				| # frozen_string_literal: true
 | |
| 
 | |
| module Submissions
 | |
|   module GenerateResultAttachments
 | |
|     FONT_SIZE = 11
 | |
|     FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
 | |
|     FONT_BOLD_PATH = '/fonts/GoNotoKurrent-Bold.ttf'
 | |
|     FONT_NAME = if File.exist?(FONT_PATH)
 | |
|                   FONT_PATH
 | |
|                 else
 | |
|                   'Helvetica'
 | |
|                 end
 | |
| 
 | |
|     ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
 | |
|     BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
 | |
| 
 | |
|     FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
 | |
|                        FONT_BOLD_PATH
 | |
|                      else
 | |
|                        'Helvetica'
 | |
|                      end
 | |
| 
 | |
|     FONT_ITALIC_NAME = 'Helvetica'
 | |
|     FONT_BOLD_ITALIC_NAME = 'Helvetica'
 | |
| 
 | |
|     FONT_VARIANS = {
 | |
|       none: FONT_NAME,
 | |
|       bold: FONT_BOLD_NAME,
 | |
|       italic: FONT_ITALIC_NAME,
 | |
|       bold_italic: FONT_BOLD_ITALIC_NAME
 | |
|     }.freeze
 | |
| 
 | |
|     PDFA_FONT_VARIANS = {
 | |
|       none: FONT_NAME,
 | |
|       bold: FONT_BOLD_NAME,
 | |
|       italic: FONT_NAME,
 | |
|       bold_italic: FONT_BOLD_NAME
 | |
|     }.freeze
 | |
| 
 | |
|     SIGN_REASON = 'Signed by %<name>s with DocuSeal.com'
 | |
| 
 | |
|     RTL_REGEXP = TextUtils::RTL_REGEXP
 | |
| 
 | |
|     TEXT_LEFT_MARGIN = 1
 | |
|     TEXT_TOP_MARGIN = 1
 | |
|     MAX_PAGE_ROTATE = 50
 | |
| 
 | |
|     A4_SIZE = [595, 842].freeze
 | |
| 
 | |
|     TESTING_FOOTER = 'Testing Document - NOT LEGALLY BINDING'
 | |
|     DEFAULT_FONTS = %w[Times Helvetica Courier].freeze
 | |
|     FONTS_LINE_HEIGHT = {
 | |
|       'Times' => 1.5,
 | |
|       'Helvetica' => 1.5,
 | |
|       'Courier' => 1.6
 | |
|     }.freeze
 | |
| 
 | |
|     PDFA_FONT_MAP = {
 | |
|       FONT_NAME => PDFA_FONT_VARIANS,
 | |
|       'Helvetica' => PDFA_FONT_VARIANS,
 | |
|       'Times' => PDFA_FONT_VARIANS,
 | |
|       'Courier' => PDFA_FONT_VARIANS
 | |
|     }.freeze
 | |
| 
 | |
|     MISSING_GLYPH_REPLACE = {
 | |
|       '▪' => '-',
 | |
|       '✔️' => 'V',
 | |
|       '✔' => 'V',
 | |
|       '✓' => 'V',
 | |
|       '✅' => 'V'
 | |
|     }.freeze
 | |
| 
 | |
|     MISSING_GLYPH_REPLACE_TYPE1 = {
 | |
|       '▪' => :bullet,
 | |
|       '✔️' => :V,
 | |
|       '✔' => :V,
 | |
|       '✓' => :V,
 | |
|       '✅' => :V
 | |
|     }.freeze
 | |
| 
 | |
|     module_function
 | |
| 
 | |
|     # rubocop:disable Metrics
 | |
|     def call(submitter)
 | |
|       return generate_detached_signature_attachments(submitter) if detached_signature?(submitter)
 | |
| 
 | |
|       pdfs_index = generate_pdfs(submitter)
 | |
| 
 | |
|       account = submitter.account
 | |
|       submission = submitter.submission
 | |
| 
 | |
|       pkcs = Accounts.load_signing_pkcs(account)
 | |
|       tsa_url = Accounts.load_timeserver_url(account)
 | |
| 
 | |
|       image_pdfs = []
 | |
|       original_documents = submission.schema_documents.preload(:blob)
 | |
| 
 | |
|       result_attachments =
 | |
|         submission.template_schema.filter_map do |item|
 | |
|           pdf = pdfs_index[item['attachment_uuid']]
 | |
| 
 | |
|           next if pdf.nil?
 | |
| 
 | |
|           if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
 | |
|             pdf = normalize_image_pdf(pdf)
 | |
| 
 | |
|             image_pdfs << pdf
 | |
|           end
 | |
| 
 | |
|           build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:,
 | |
|                                uuid: item['attachment_uuid'],
 | |
|                                name: item['name'])
 | |
|         end
 | |
| 
 | |
|       return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
 | |
| 
 | |
|       images_pdf =
 | |
|         image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
 | |
|           pdf.pages.each { |page| doc.pages << doc.import(page) }
 | |
|         end
 | |
| 
 | |
|       images_pdf = normalize_image_pdf(images_pdf)
 | |
| 
 | |
|       images_pdf_attachment =
 | |
|         build_pdf_attachment(
 | |
|           pdf: images_pdf,
 | |
|           submitter:,
 | |
|           tsa_url:,
 | |
|           pkcs:,
 | |
|           uuid: images_pdf_uuid(original_documents.select(&:image?)),
 | |
|           name: submission.name || submission.template.name
 | |
|         )
 | |
| 
 | |
|       ApplicationRecord.no_touching do
 | |
|         (result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     def generate_pdfs(submitter)
 | |
|       configs = submitter.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
 | |
|                                                               AccountConfig::WITH_SIGNATURE_ID,
 | |
|                                                               AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
 | |
|                                                               AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
 | |
| 
 | |
|       with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
 | |
|       is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
 | |
|       with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
 | |
|       with_signature_id_reason =
 | |
|         configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
 | |
| 
 | |
|       pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten)
 | |
| 
 | |
|       if with_signature_id || submitter.account.testing?
 | |
|         pdfs_index.each_value do |pdf|
 | |
|           next if pdf.trailer.info[:DocumentID].present?
 | |
| 
 | |
|           font = pdf.fonts.add(FONT_NAME)
 | |
| 
 | |
|           document_id = Digest::MD5.hexdigest(submitter.submission.slug).upcase
 | |
| 
 | |
|           pdf.trailer.info[:DocumentID] = document_id
 | |
|           pdf.pages.each do |page|
 | |
|             font_size = (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * 9).to_i
 | |
|             cnv = page.canvas(type: :overlay)
 | |
| 
 | |
|             text =
 | |
|               if submitter.account.testing?
 | |
|                 if with_signature_id
 | |
|                   "#{TESTING_FOOTER} | ID: #{document_id}"
 | |
|                 else
 | |
|                   TESTING_FOOTER
 | |
|                 end
 | |
|               else
 | |
|                 "#{I18n.t('document_id',
 | |
|                           locale: submitter.metadata.fetch('lang', submitter.account.locale))}: #{document_id}"
 | |
|               end
 | |
| 
 | |
|             text = HexaPDF::Layout::TextFragment.create(
 | |
|               text, font:, font_size:, underlays: [
 | |
|                 lambda do |canv, box|
 | |
|                   canv.fill_color('white').rectangle(-1, 0, box.width + 2, box.height).fill
 | |
|                 end
 | |
|               ]
 | |
|             )
 | |
| 
 | |
|             HexaPDF::Layout::TextLayouter.new(font:, font_size:)
 | |
|                                          .fit([text], page.box.width, page.box.height)
 | |
|                                          .draw(cnv, 1, font_size * 1.37)
 | |
|           end
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:,
 | |
|                                                                       with_submitter_timezone:,
 | |
|                                                                       with_signature_id_reason:)
 | |
|     end
 | |
| 
 | |
|     def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
 | |
|                               with_submitter_timezone: false, with_signature_id_reason: true)
 | |
|       cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
 | |
| 
 | |
|       attachments_data_cache = {}
 | |
| 
 | |
|       return pdfs_index if submitter.submission.template_fields.blank?
 | |
| 
 | |
|       with_headings = find_last_submitter(submitter.submission, submitter:).blank? if with_headings.nil?
 | |
| 
 | |
|       locale = submitter.metadata.fetch('lang', account.locale)
 | |
| 
 | |
|       submitter.submission.template_fields.each do |field|
 | |
|         next if field['type'] == 'heading' && !with_headings
 | |
|         next if field['submitter_uuid'] != submitter.uuid && field['type'] != 'heading'
 | |
| 
 | |
|         field.fetch('areas', []).each do |area|
 | |
|           pdf = pdfs_index[area['attachment_uuid']]
 | |
| 
 | |
|           next if pdf.nil?
 | |
| 
 | |
|           page = pdf.pages[area['page']]
 | |
| 
 | |
|           next if page.nil?
 | |
| 
 | |
|           page.rotate(0, flatten: true) if page[:Rotate] != 0
 | |
| 
 | |
|           page[:Annots] ||= []
 | |
|           page[:Annots] = page[:Annots].try(:reject) do |e|
 | |
|             next if e.is_a?(Integer)
 | |
| 
 | |
|             e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
 | |
|           end || page[:Annots]
 | |
| 
 | |
|           width = page.box.width
 | |
|           height = page.box.height
 | |
| 
 | |
|           preferences_font_size = field.dig('preferences', 'font_size').then { |num| num.present? ? num.to_i : nil }
 | |
| 
 | |
|           font_size   = preferences_font_size
 | |
|           font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
 | |
| 
 | |
|           fill_color = field.dig('preferences', 'color').to_s.delete_prefix('#').presence
 | |
| 
 | |
|           font_name = field.dig('preferences', 'font')
 | |
|           font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym
 | |
| 
 | |
|           font_name = FONT_NAME unless font_name.in?(DEFAULT_FONTS)
 | |
| 
 | |
|           if font_variant != :none && font_name == FONT_NAME
 | |
|             font_name = FONT_VARIANS[font_variant] if FONT_VARIANS[font_variant]
 | |
|             font_variant = nil unless font_name.in?(DEFAULT_FONTS)
 | |
|           end
 | |
| 
 | |
|           font = pdf.fonts.add(font_name, variant: font_variant, custom_encoding: font_name.in?(DEFAULT_FONTS))
 | |
| 
 | |
|           value = submitter.values[field['uuid']]
 | |
|           value = field['default_value'] if field['type'] == 'heading'
 | |
| 
 | |
|           text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
 | |
|                        (value.to_s.match?(RTL_REGEXP) ? :right : :left)
 | |
| 
 | |
|           text_valign = (field.dig('preferences', 'valign').to_s.presence || 'center').to_sym
 | |
| 
 | |
|           layouter = HexaPDF::Layout::TextLayouter.new(text_valign:, text_align:, font:, font_size:)
 | |
| 
 | |
|           next if Array.wrap(value).compact_blank.blank?
 | |
| 
 | |
|           if is_flatten
 | |
|             begin
 | |
|               page.flatten_annotations
 | |
|             rescue StandardError => e
 | |
|               Rollbar.error(e) if defined?(Rollbar)
 | |
|             end
 | |
|           end
 | |
| 
 | |
|           canvas = page.canvas(type: :overlay)
 | |
|           canvas.font(FONT_NAME, size: font_size)
 | |
| 
 | |
|           field_type = field['type']
 | |
|           field_type = 'file' if field_type == 'image' &&
 | |
|                                  !submitter.attachments.find { |a| a.uuid == value }.image?
 | |
| 
 | |
|           if field_type == 'signature' && field.dig('preferences', 'with_signature_id').in?([true, false])
 | |
|             with_signature_id = field['preferences']['with_signature_id']
 | |
|           end
 | |
| 
 | |
|           case field_type
 | |
|           when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
 | |
|             attachment = submitter.attachments.find { |a| a.uuid == value }
 | |
| 
 | |
|             image =
 | |
|               begin
 | |
|                 load_vips_image(attachment, attachments_data_cache).autorot
 | |
|               rescue Vips::Error
 | |
|                 next unless attachment.content_type.starts_with?('image/')
 | |
|                 next if attachment.byte_size.zero?
 | |
| 
 | |
|                 raise
 | |
|               end
 | |
| 
 | |
|             reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence
 | |
| 
 | |
|             reason_string =
 | |
|               I18n.with_locale(locale) do
 | |
|                 timezone = submitter.account.timezone
 | |
|                 timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
 | |
| 
 | |
|                 if with_signature_id_reason
 | |
|                   "#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \
 | |
|                     "#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \
 | |
|                     "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
 | |
|                     "#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
 | |
|                 else
 | |
|                   "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
 | |
|                     "#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
 | |
|                 end
 | |
|               end
 | |
| 
 | |
|             reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
 | |
|                                                                font:,
 | |
|                                                                font_size: (font_size / 1.8).to_i)
 | |
| 
 | |
|             if area['h']&.positive? && (area['w'].to_f / area['h']) > 6
 | |
|               area_x = area['x'] * width
 | |
|               area_y = area['y'] * height
 | |
|               area_w = area['w'] * width
 | |
|               area_h = area['h'] * height
 | |
| 
 | |
|               half_width = area_w / 2.0
 | |
|               scale = [half_width / image.width, area_h / image.height].min
 | |
|               image_width = image.width * scale
 | |
|               image_height = image.height * scale
 | |
|               image_x = area_x + ((half_width - image_width) / 2.0)
 | |
|               image_y = height - area_y - image_height
 | |
| 
 | |
|               io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
 | |
| 
 | |
|               canvas.image(io, at: [image_x, image_y], width: image_width, height: image_height)
 | |
| 
 | |
|               id_string = "ID: #{attachment.uuid}".upcase
 | |
| 
 | |
|               while true
 | |
|                 text = HexaPDF::Layout::TextFragment.create(id_string,
 | |
|                                                             font:,
 | |
|                                                             font_size: (font_size / 1.8).to_i)
 | |
| 
 | |
|                 result = layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
 | |
| 
 | |
|                 break if result.status == :success
 | |
| 
 | |
|                 id_string = "#{id_string.delete_suffix('...')[0..-2]}..."
 | |
| 
 | |
|                 break if id_string.length < 8
 | |
|               end
 | |
| 
 | |
|               text_x = area_x + half_width
 | |
|               text_y = height - area_y
 | |
| 
 | |
|               reason_result = layouter.fit([reason_text], half_width, height)
 | |
| 
 | |
|               layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
 | |
|                       .draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
 | |
| 
 | |
|               layouter.fit([reason_text], half_width, reason_result.lines.sum(&:height))
 | |
|                       .draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y - TEXT_TOP_MARGIN - result.lines.sum(&:height))
 | |
|             else
 | |
|               id_string = "ID: #{attachment.uuid}".upcase
 | |
| 
 | |
|               loop do
 | |
|                 text = HexaPDF::Layout::TextFragment.create(id_string,
 | |
|                                                             font:,
 | |
|                                                             font_size: (font_size / 1.8).to_i)
 | |
| 
 | |
|                 result = layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
 | |
| 
 | |
|                 break if result.status == :success
 | |
| 
 | |
|                 id_string = "#{id_string.delete_suffix('...')[0..-2]}..."
 | |
| 
 | |
|                 break if id_string.length < 8
 | |
|               end
 | |
| 
 | |
|               reason_result = layouter.fit([reason_text], area['w'] * width, height)
 | |
|               text_height = result.lines.sum(&:height) + reason_result.lines.sum(&:height)
 | |
| 
 | |
|               image_height = (area['h'] * height) - text_height
 | |
|               image_height = (area['h'] * height) / 2 if image_height < (area['h'] * height) / 2
 | |
| 
 | |
|               scale = [(area['w'] * width) / image.width, image_height / image.height].min
 | |
| 
 | |
|               io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
 | |
| 
 | |
|               layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
 | |
|                       .draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
 | |
|                             height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height)
 | |
| 
 | |
|               layouter.fit([reason_text], area['w'] * width, reason_result.lines.sum(&:height))
 | |
|                       .draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
 | |
|                             height - (area['y'] * height) - TEXT_TOP_MARGIN -
 | |
|                             result.lines.sum(&:height) - image_height)
 | |
| 
 | |
|               canvas.image(
 | |
|                 io,
 | |
|                 at: [
 | |
|                   (area['x'] * width) + (area['w'] * width / 2) - ((image.width * scale) / 2),
 | |
|                   height - (area['y'] * height) - (image.height * scale / 2) - (image_height / 2)
 | |
|                 ],
 | |
|                 width: image.width * scale,
 | |
|                 height: image.height * scale
 | |
|               )
 | |
|             end
 | |
|           when 'image', 'signature', 'initials', 'stamp'
 | |
|             attachment = submitter.attachments.find { |a| a.uuid == value }
 | |
| 
 | |
|             image =
 | |
|               begin
 | |
|                 load_vips_image(attachment, attachments_data_cache).autorot
 | |
|               rescue Vips::Error
 | |
|                 next unless attachment.content_type.starts_with?('image/')
 | |
|                 next if attachment.byte_size.zero?
 | |
| 
 | |
|                 raise
 | |
|               end
 | |
| 
 | |
|             scale = [(area['w'] * width) / image.width,
 | |
|                      (area['h'] * height) / image.height].min
 | |
| 
 | |
|             io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
 | |
| 
 | |
|             canvas.image(
 | |
|               io,
 | |
|               at: [
 | |
|                 (area['x'] * width) + (area['w'] * width / 2) - ((image.width * scale) / 2),
 | |
|                 height - (area['y'] * height) - (image.height * scale / 2) - (area['h'] * height / 2)
 | |
|               ],
 | |
|               width: image.width * scale,
 | |
|               height: image.height * scale
 | |
|             )
 | |
|           when 'file', 'payment'
 | |
|             items = Array.wrap(value).each_with_object([]) do |uuid, acc|
 | |
|               attachment = submitter.attachments.find { |a| a.uuid == uuid }
 | |
| 
 | |
|               acc << HexaPDF::Layout::InlineBox.create(width: font_size, height: font_size,
 | |
|                                                        margin: [0, 1, -2, 0]) do |cv, box|
 | |
|                 cv.image(PdfIcons.paperclip_io, at: [0, 0], width: box.content_width)
 | |
|               end
 | |
| 
 | |
|               acc << HexaPDF::Layout::TextFragment.create("#{attachment.filename}\n", font:,
 | |
|                                                                                       font_size:)
 | |
|             end
 | |
| 
 | |
|             lines = layouter.fit(items, area['w'] * width, height).lines
 | |
| 
 | |
|             box_height = lines.sum(&:height)
 | |
|             height_diff = [0, box_height - (area['h'] * height)].max
 | |
| 
 | |
|             lines.each_with_index.reduce(0) do |acc, (line, index)|
 | |
|               next acc unless line.items.first.is_a?(HexaPDF::Layout::InlineBox)
 | |
| 
 | |
|               attachment_uuid = Array.wrap(value)[acc]
 | |
|               attachment = submitter.attachments.find { |a| a.uuid == attachment_uuid }
 | |
| 
 | |
|               next_index = lines[(index + 1)..].index { |l| l.items.first.is_a?(HexaPDF::Layout::InlineBox) }
 | |
|               next_index += index if next_index
 | |
|               next_index ||= lines.size - 1
 | |
| 
 | |
|               diff = ((area['h'] * height) / 2) - (lines.sum(&:height) / 2)
 | |
| 
 | |
|               page[:Annots] << pdf.add(
 | |
|                 {
 | |
|                   Type: :Annot, Subtype: :Link,
 | |
|                   Rect: [
 | |
|                     (area['x'] * width) + TEXT_LEFT_MARGIN,
 | |
|                     height - (area['y'] * height) - lines[...index].sum(&:height) +
 | |
|                     height_diff - (height_diff.zero? ? diff : 0),
 | |
|                     (area['x'] * width) + (area['w'] * width) + TEXT_LEFT_MARGIN,
 | |
|                     height - (area['y'] * height) - lines[..next_index].sum(&:height) +
 | |
|                     height_diff - (height_diff.zero? ? diff : 0)
 | |
|                   ],
 | |
|                   A: { Type: :Action, S: :URI,
 | |
|                        URI: ActiveStorage::Blob.proxy_url(attachment.blob) }
 | |
|                 }
 | |
|               )
 | |
| 
 | |
|               acc + 1
 | |
|             end
 | |
| 
 | |
|             layouter.fit(items, area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
 | |
|                     .draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
 | |
|                           height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
 | |
|           when ->(type) { type == 'checkbox' || (type.in?(%w[multiple radio]) && area['option_uuid'].present?) }
 | |
|             if field['type'].in?(%w[multiple radio])
 | |
|               option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
 | |
| 
 | |
|               option_name = option['value'].presence
 | |
|               option_name ||= "#{I18n.t('option', locale: locale)} #{field['options'].index(option) + 1}"
 | |
| 
 | |
|               value = Array.wrap(value).include?(option_name)
 | |
|             end
 | |
| 
 | |
|             next unless value == true
 | |
| 
 | |
|             scale = [(area['w'] * width) / PdfIcons::WIDTH, (area['h'] * height) / PdfIcons::HEIGHT].min
 | |
| 
 | |
|             canvas.image(
 | |
|               PdfIcons.check_io,
 | |
|               at: [
 | |
|                 (area['x'] * width) + (area['w'] * width / 2) - (PdfIcons::WIDTH * scale / 2),
 | |
|                 height - (area['y'] * height) - (area['h'] * height / 2) - (PdfIcons::HEIGHT * scale / 2)
 | |
|               ],
 | |
|               width: PdfIcons::WIDTH * scale,
 | |
|               height: PdfIcons::HEIGHT * scale
 | |
|             )
 | |
|           when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
 | |
|             cell_width = area['cell_w'] * width
 | |
| 
 | |
|             if (mask = field.dig('preferences', 'mask').presence)
 | |
|               value = TextUtils.mask_value(value, mask)
 | |
|             end
 | |
| 
 | |
|             chars = TextUtils.maybe_rtl_reverse(value).chars
 | |
|             chars = chars.reverse if field.dig('preferences', 'align') == 'right'
 | |
| 
 | |
|             chars.each_with_index do |char, index|
 | |
|               next if char.blank?
 | |
| 
 | |
|               text = HexaPDF::Layout::TextFragment.create(char, font:,
 | |
|                                                                 fill_color:,
 | |
|                                                                 font_size:)
 | |
| 
 | |
|               line_height = layouter.fit([text], cell_width, height).lines.first.height
 | |
| 
 | |
|               if preferences_font_size.blank? && line_height > (area['h'] * height)
 | |
|                 text = HexaPDF::Layout::TextFragment.create(char,
 | |
|                                                             font:,
 | |
|                                                             fill_color:,
 | |
|                                                             font_size: (font_size / 1.4).to_i)
 | |
| 
 | |
|                 line_height = layouter.fit([text], cell_width, height).lines.first.height
 | |
|               end
 | |
| 
 | |
|               if preferences_font_size.blank? && line_height > (area['h'] * height)
 | |
|                 text = HexaPDF::Layout::TextFragment.create(char,
 | |
|                                                             font:,
 | |
|                                                             fill_color:,
 | |
|                                                             font_size: (font_size / 1.9).to_i)
 | |
| 
 | |
|                 line_height = layouter.fit([text], cell_width, height).lines.first.height
 | |
|               end
 | |
| 
 | |
|               x =
 | |
|                 if field.dig('preferences', 'align') == 'right'
 | |
|                   ((area['x'] + area['w']) * width) - (cell_width * (index + 1))
 | |
|                 else
 | |
|                   (area['x'] * width) + (cell_width * index)
 | |
|                 end
 | |
| 
 | |
|               cell_layouter.fit([text], cell_width, [line_height, area['h'] * height].max)
 | |
|                            .draw(canvas, x, height - (area['y'] * height))
 | |
|             end
 | |
|           else
 | |
|             if field['type'] == 'date'
 | |
|               value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), locale)
 | |
|             end
 | |
| 
 | |
|             value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number'
 | |
| 
 | |
|             value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
 | |
| 
 | |
|             if (mask = field.dig('preferences', 'mask').presence)
 | |
|               value = TextUtils.mask_value(value, mask)
 | |
|             end
 | |
| 
 | |
|             text_params = { font:, fill_color:, font_size: }
 | |
|             text_params[:line_height] = text_params[:font_size] * (FONTS_LINE_HEIGHT[font_name] || 1)
 | |
| 
 | |
|             text = HexaPDF::Layout::TextFragment.create(value, **text_params)
 | |
| 
 | |
|             lines = layouter.fit([text], area['w'] * width, height).lines
 | |
|             box_height = lines.sum(&:height)
 | |
| 
 | |
|             if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
 | |
|               text_params[:font_size] = (font_size / 1.4).to_i
 | |
|               text_params[:line_height] = text_params[:font_size] * (FONTS_LINE_HEIGHT[font_name] || 1)
 | |
| 
 | |
|               text = HexaPDF::Layout::TextFragment.create(value, **text_params)
 | |
| 
 | |
|               lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
 | |
| 
 | |
|               box_height = lines.sum(&:height)
 | |
|             end
 | |
| 
 | |
|             if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
 | |
|               text_params[:font_size] = (font_size / 1.9).to_i
 | |
|               text_params[:line_height] = text_params[:font_size] * (FONTS_LINE_HEIGHT[font_name] || 1)
 | |
| 
 | |
|               text = HexaPDF::Layout::TextFragment.create(value, **text_params)
 | |
| 
 | |
|               lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
 | |
| 
 | |
|               box_height = lines.sum(&:height)
 | |
|             end
 | |
| 
 | |
|             height_diff = [0, box_height - (area['h'] * height)].max
 | |
| 
 | |
|             right_align_x_adjustment =
 | |
|               if field['type'].in?(%w[date number]) && text_align != :left
 | |
|                 (width - (area['w'] * width)) / (text_align == :center ? 2.0 : 1)
 | |
|               else
 | |
|                 0
 | |
|               end
 | |
| 
 | |
|             align_y_diff =
 | |
|               if text_valign == :top
 | |
|                 0
 | |
|               elsif text_valign == :bottom
 | |
|                 height_diff + TEXT_TOP_MARGIN
 | |
|               else
 | |
|                 height_diff / 2
 | |
|               end
 | |
| 
 | |
|             layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width,
 | |
|                          height_diff.positive? ? box_height : area['h'] * height)
 | |
|                     .draw(canvas, (area['x'] * width) - right_align_x_adjustment + TEXT_LEFT_MARGIN,
 | |
|                           height - (area['y'] * height) + align_y_diff - TEXT_TOP_MARGIN)
 | |
|           end
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       pdfs_index
 | |
|     end
 | |
| 
 | |
|     def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
 | |
|       io = StringIO.new
 | |
| 
 | |
|       pdf.trailer.info[:Creator] = info_creator
 | |
| 
 | |
|       if Docuseal.pdf_format == 'pdf/a-3b'
 | |
|         pdf.task(:pdfa, level: '3b')
 | |
|         pdf.config['font.map'] = PDFA_FONT_MAP
 | |
|       end
 | |
| 
 | |
|       sign_reason = fetch_sign_reason(submitter)
 | |
| 
 | |
|       if sign_reason && pkcs
 | |
|         sign_params = {
 | |
|           reason: sign_reason,
 | |
|           **build_signing_params(submitter, pkcs, tsa_url)
 | |
|         }
 | |
| 
 | |
|         pdf.pages.first[:Annots] = [] unless pdf.pages.first[:Annots].respond_to?(:<<)
 | |
| 
 | |
|         begin
 | |
|           pdf.sign(io, write_options: { validate: false }, **sign_params)
 | |
|         rescue HexaPDF::MalformedPDFError => e
 | |
|           Rollbar.error(e) if defined?(Rollbar)
 | |
| 
 | |
|           pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
 | |
|         end
 | |
| 
 | |
|         maybe_enable_ltv(io, sign_params)
 | |
|       else
 | |
|         begin
 | |
|           pdf.write(io, incremental: true, validate: false)
 | |
|         rescue HexaPDF::MalformedPDFError => e
 | |
|           Rollbar.error(e) if defined?(Rollbar)
 | |
| 
 | |
|           pdf.write(io, incremental: false, validate: false)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       ActiveStorage::Attachment.new(
 | |
|         blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"),
 | |
|         metadata: { original_uuid: uuid,
 | |
|                     analyzed: true,
 | |
|                     sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
 | |
|         name: 'documents',
 | |
|         record: submitter
 | |
|       )
 | |
|     end
 | |
|     # rubocop:enable Metrics
 | |
| 
 | |
|     def maybe_enable_ltv(io, _sign_params)
 | |
|       io
 | |
|     end
 | |
| 
 | |
|     def build_signing_params(_submitter, pkcs, tsa_url)
 | |
|       params = {
 | |
|         certificate: pkcs.certificate,
 | |
|         key: pkcs.key,
 | |
|         certificate_chain: pkcs.ca_certs || []
 | |
|       }
 | |
| 
 | |
|       if tsa_url
 | |
|         params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
 | |
|         params[:signature_size] = 20_000
 | |
|       end
 | |
| 
 | |
|       params
 | |
|     end
 | |
| 
 | |
|     def images_pdf_uuid(attachments)
 | |
|       Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':'))
 | |
|     end
 | |
| 
 | |
|     def build_pdfs_index(submission, submitter: nil, flatten: true)
 | |
|       latest_submitter = find_last_submitter(submission, submitter:)
 | |
| 
 | |
|       Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
 | |
| 
 | |
|       documents   = latest_submitter&.documents&.preload(:blob).to_a.presence
 | |
|       documents ||= submission.schema_documents.preload(:blob)
 | |
| 
 | |
|       attachment_uuids = Submissions.filtered_conditions_schema(submission).pluck('attachment_uuid')
 | |
|       attachments_index = documents.index_by { |a| a.metadata['original_uuid'] || a.uuid }
 | |
| 
 | |
|       attachment_uuids.each_with_object({}) do |uuid, acc|
 | |
|         attachment = attachments_index[uuid]
 | |
|         attachment ||= submission.schema_documents.preload(:blob).find { |a| a.uuid == uuid }
 | |
| 
 | |
|         next unless attachment
 | |
| 
 | |
|         pdf =
 | |
|           if attachment.image?
 | |
|             build_pdf_from_image(attachment)
 | |
|           else
 | |
|             HexaPDF::Document.new(io: StringIO.new(attachment.download))
 | |
|           end
 | |
| 
 | |
|         pdf = maybe_rotate_pdf(pdf)
 | |
| 
 | |
|         maybe_flatten_pdf(pdf) if flatten
 | |
| 
 | |
|         pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
 | |
| 
 | |
|         acc[uuid] = pdf
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     def maybe_flatten_pdf(pdf)
 | |
|       pdf.acro_form.create_appearances(force: true) if pdf.acro_form && pdf.acro_form[:NeedAppearances]
 | |
|       pdf.acro_form&.flatten
 | |
|     rescue HexaPDF::MissingGlyphError
 | |
|       nil
 | |
|     rescue StandardError => e
 | |
|       Rollbar.error(e) if defined?(Rollbar)
 | |
|     end
 | |
| 
 | |
|     def maybe_rotate_pdf(pdf)
 | |
|       return pdf if pdf.pages.size > MAX_PAGE_ROTATE
 | |
| 
 | |
|       is_rotated = pdf.pages.filter_map do |page|
 | |
|         page.rotate(0, flatten: true) if page[:Rotate] != 0
 | |
|       end.present?
 | |
| 
 | |
|       return pdf unless is_rotated
 | |
| 
 | |
|       io = StringIO.new
 | |
| 
 | |
|       pdf.write(io, incremental: false, validate: false)
 | |
| 
 | |
|       HexaPDF::Document.new(io:)
 | |
|     rescue StandardError => e
 | |
|       Rollbar.error(e) if defined?(Rollbar)
 | |
| 
 | |
|       pdf
 | |
|     end
 | |
| 
 | |
|     def on_missing_glyph(character, font_wrapper)
 | |
|       Rails.logger.info("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
 | |
| 
 | |
|       replace_with =
 | |
|         if font_wrapper.font_type == :Type1
 | |
|           MISSING_GLYPH_REPLACE_TYPE1[character] || :space
 | |
|         else
 | |
|           (MISSING_GLYPH_REPLACE[character] || ' ').bytes.first - 29
 | |
|         end
 | |
| 
 | |
|       font_wrapper.custom_glyph(replace_with, character)
 | |
|     end
 | |
| 
 | |
|     def find_last_submitter(submission, submitter: nil)
 | |
|       submission.submitters
 | |
|                 .select(&:completed_at?)
 | |
|                 .select { |e| submitter.nil? ? true : e.id != submitter.id && e.completed_at <= submitter.completed_at }
 | |
|                 .max_by(&:completed_at)
 | |
|     end
 | |
| 
 | |
|     def build_pdf_from_image(attachment)
 | |
|       pdf = HexaPDF::Document.new
 | |
| 
 | |
|       page = pdf.pages.add
 | |
| 
 | |
|       image = attachment.preview_images.first
 | |
| 
 | |
|       scale = [A4_SIZE.first / image.metadata['width'].to_f,
 | |
|                A4_SIZE.last / image.metadata['height'].to_f].min
 | |
| 
 | |
|       page.box.width = image.metadata['width'] * scale
 | |
|       page.box.height = image.metadata['height'] * scale
 | |
| 
 | |
|       page.canvas.image(
 | |
|         StringIO.new(image.download),
 | |
|         at: [0, 0],
 | |
|         width: page.box.width,
 | |
|         height: page.box.height
 | |
|       )
 | |
| 
 | |
|       pdf
 | |
|     end
 | |
| 
 | |
|     def normalize_image_pdf(pdf)
 | |
|       io = StringIO.new
 | |
|       pdf.write(io)
 | |
|       io.rewind
 | |
| 
 | |
|       HexaPDF::Document.new(io:)
 | |
|     end
 | |
| 
 | |
|     def sign_reason(name)
 | |
|       format(SIGN_REASON, name:)
 | |
|     end
 | |
| 
 | |
|     def single_sign_reason(submitter)
 | |
|       signers = submitter.submission.submitters.sort_by(&:completed_at).map { |s| s.email || s.name || s.phone }
 | |
| 
 | |
|       format(SIGN_REASON, name: signers.reverse.join(', '))
 | |
|     end
 | |
| 
 | |
|     def fetch_sign_reason(submitter)
 | |
|       reason_name = submitter.email || submitter.name || submitter.phone
 | |
| 
 | |
|       config =
 | |
|         if Docuseal.multitenant?
 | |
|           AccountConfig.where(account: submitter.account, key: AccountConfig::ESIGNING_PREFERENCE_KEY)
 | |
|                        .first_or_initialize(value: 'single')
 | |
|         else
 | |
|           AccountConfig.where(key: AccountConfig::ESIGNING_PREFERENCE_KEY)
 | |
|                        .first_or_initialize(value: 'single')
 | |
|         end
 | |
| 
 | |
|       return sign_reason(reason_name) if config.value == 'multiple'
 | |
| 
 | |
|       if !submitter.submission.submitters.exists?(completed_at: nil) &&
 | |
|          submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
 | |
|         return single_sign_reason(submitter)
 | |
|       end
 | |
| 
 | |
|       nil
 | |
|     end
 | |
| 
 | |
|     def info_creator
 | |
|       "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})"
 | |
|     end
 | |
| 
 | |
|     def detached_signature?(_submitter)
 | |
|       false
 | |
|     end
 | |
| 
 | |
|     def generate_detached_signature_attachments(_submitter)
 | |
|       []
 | |
|     end
 | |
| 
 | |
|     def load_vips_image(attachment, cache = {})
 | |
|       cache[attachment.uuid] ||= attachment.download
 | |
| 
 | |
|       data = cache[attachment.uuid]
 | |
| 
 | |
|       if ICO_REGEXP.match?(attachment.content_type)
 | |
|         LoadIco.call(data)
 | |
|       elsif BMP_REGEXP.match?(attachment.content_type)
 | |
|         LoadBmp.call(data)
 | |
|       else
 | |
|         Vips::Image.new_from_buffer(data, '')
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     def h
 | |
|       Rails.application.routes.url_helpers
 | |
|     end
 | |
|   end
 | |
| end
 |