Files
docuseal/lib/submissions/generate_result_attachments.rb
T

975 lines
36 KiB
Ruby
Raw Normal View History

2023-05-21 17:59:36 +03:00
# frozen_string_literal: true
module Submissions
module GenerateResultAttachments
2023-06-18 16:30:35 +03:00
FONT_SIZE = 11
2024-02-16 11:34:30 +02:00
FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
2025-03-05 12:12:16 +02:00
FONT_BOLD_PATH = '/fonts/GoNotoKurrent-Bold.ttf'
2023-08-25 00:27:56 +03:00
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
else
'Helvetica'
end
2023-05-28 23:20:48 +03:00
2025-05-19 14:06:37 +03:00
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
2025-03-05 12:12:16 +02:00
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
2025-07-10 12:13:27 +03:00
PDFA_FONT_VARIANS = {
none: FONT_NAME,
bold: FONT_BOLD_NAME,
italic: FONT_NAME,
bold_italic: FONT_BOLD_NAME
}.freeze
2024-10-31 14:25:37 +02:00
SIGN_REASON = 'Signed by %<name>s with DocuSeal.com'
2023-06-24 12:27:34 +03:00
2024-01-15 01:45:26 +02:00
RTL_REGEXP = TextUtils::RTL_REGEXP
2024-01-07 14:19:21 +02:00
2023-06-24 22:50:12 +03:00
TEXT_LEFT_MARGIN = 1
TEXT_TOP_MARGIN = 1
2025-07-04 09:45:14 +03:00
MAX_PAGE_ROTATE = 50
2023-06-24 22:50:12 +03:00
2023-06-22 01:31:39 +03:00
A4_SIZE = [595, 842].freeze
2024-08-05 10:45:05 +03:00
TESTING_FOOTER = 'Testing Document - NOT LEGALLY BINDING'
2025-02-12 19:49:50 +02:00
DEFAULT_FONTS = %w[Times Helvetica Courier].freeze
2025-03-05 12:12:16 +02:00
FONTS_LINE_HEIGHT = {
2025-07-14 12:53:15 +03:00
'Times' => 1.5,
'Helvetica' => 1.5,
2025-03-05 12:12:16 +02:00
'Courier' => 1.6
}.freeze
2024-08-05 10:45:05 +03:00
2025-07-10 12:13:27 +03:00
PDFA_FONT_MAP = {
FONT_NAME => PDFA_FONT_VARIANS,
'Helvetica' => PDFA_FONT_VARIANS,
'Times' => PDFA_FONT_VARIANS,
'Courier' => PDFA_FONT_VARIANS
}.freeze
2024-03-15 19:02:17 +02:00
MISSING_GLYPH_REPLACE = {
'▪' => '-',
2024-02-13 22:23:42 +02:00
'✔️' => 'V',
2024-03-15 19:02:17 +02:00
'✔' => 'V',
2024-06-04 11:48:02 +03:00
'✓' => 'V',
2024-03-15 19:02:17 +02:00
'✅' => 'V'
}.freeze
MISSING_GLYPH_REPLACE_TYPE1 = {
'▪' => :bullet,
'✔️' => :V,
'✔' => :V,
2024-06-04 11:48:02 +03:00
'✓' => :V,
2024-03-15 19:02:17 +02:00
'✅' => :V
2024-02-08 21:11:31 +02:00
}.freeze
2023-05-21 17:59:36 +03:00
module_function
2023-05-28 23:20:48 +03:00
# rubocop:disable Metrics
2023-06-13 00:29:07 +03:00
def call(submitter)
2024-12-01 00:10:07 +02:00
return generate_detached_signature_attachments(submitter) if detached_signature?(submitter)
2024-05-25 20:57:36 +03:00
pdfs_index = generate_pdfs(submitter)
2023-06-27 00:57:29 +03:00
2024-05-25 20:57:36 +03:00
account = submitter.account
2025-05-31 15:15:25 +03:00
submission = submitter.submission
2024-05-25 20:57:36 +03:00
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
image_pdfs = []
2025-05-31 15:15:25 +03:00
original_documents = submission.schema_documents.preload(:blob)
2024-05-25 20:57:36 +03:00
result_attachments =
2025-05-31 15:15:25 +03:00
submission.template_schema.filter_map do |item|
2024-05-25 20:57:36 +03:00
pdf = pdfs_index[item['attachment_uuid']]
2024-12-07 01:35:59 +02:00
next if pdf.nil?
2024-08-14 19:30:16 +03:00
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = normalize_image_pdf(pdf)
2024-05-25 20:57:36 +03:00
2024-08-14 19:30:16 +03:00
image_pdfs << pdf
end
2024-05-25 20:57:36 +03:00
2024-08-14 19:30:16 +03:00
build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'],
name: item['name'])
2024-05-25 20:57:36 +03:00
end
2024-11-05 22:54:59 +02:00
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
2024-05-25 20:57:36 +03:00
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
2024-08-14 19:30:16 +03:00
images_pdf = normalize_image_pdf(images_pdf)
2024-05-25 20:57:36 +03:00
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submitter:,
tsa_url:,
pkcs:,
uuid: images_pdf_uuid(original_documents.select(&:image?)),
2025-06-07 16:20:57 +03:00
name: submission.name || submission.template.name
2024-05-25 20:57:36 +03:00
)
2024-11-05 22:54:59 +02:00
ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
2024-05-25 20:57:36 +03:00
end
def generate_pdfs(submitter)
2024-10-15 19:16:09 +03:00
configs = submitter.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
2025-07-10 20:47:52 +03:00
AccountConfig::WITH_SIGNATURE_ID,
2025-09-09 09:53:21 +03:00
AccountConfig::WITH_FILE_LINKS_KEY,
2025-07-18 10:30:37 +03:00
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
2024-07-24 20:55:35 +03:00
2024-10-15 19:16:09 +03:00
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
2025-07-10 20:47:52 +03:00
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
2025-09-09 09:53:21 +03:00
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
2025-07-18 10:30:37 +03:00
with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
2023-05-28 23:20:48 +03:00
2024-10-15 19:16:09 +03:00
pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten)
2023-05-28 23:20:48 +03:00
2024-08-05 10:45:05 +03:00
if with_signature_id || submitter.account.testing?
2024-07-24 20:55:35 +03:00
pdfs_index.each_value do |pdf|
next if pdf.trailer.info[:DocumentID].present?
2024-08-27 23:36:44 +03:00
font = pdf.fonts.add(FONT_NAME)
2024-08-05 10:45:05 +03:00
document_id = Digest::MD5.hexdigest(submitter.submission.slug).upcase
pdf.trailer.info[:DocumentID] = document_id
2024-07-24 20:55:35 +03:00
pdf.pages.each do |page|
2026-02-12 14:27:15 +02:00
font_size = [(([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * 9).to_i, 4].max
2024-07-24 20:55:35 +03:00
cnv = page.canvas(type: :overlay)
2024-08-05 10:45:05 +03:00
text =
if submitter.account.testing?
if with_signature_id
"#{TESTING_FOOTER} | ID: #{document_id}"
else
TESTING_FOOTER
end
else
2025-06-03 09:52:46 +03:00
"#{I18n.t('document_id',
locale: submitter.metadata.fetch('lang', submitter.account.locale))}: #{document_id}"
2024-08-05 10:45:05 +03:00
end
2024-08-27 23:36:44 +03:00
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)
2024-07-24 20:55:35 +03:00
end
end
end
2025-07-10 20:47:52 +03:00
fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:,
2025-07-18 10:30:37 +03:00
with_submitter_timezone:,
2025-09-09 09:53:21 +03:00
with_file_links:,
2025-07-18 10:30:37 +03:00
with_signature_id_reason:)
2024-10-15 19:16:09 +03:00
end
2025-07-10 20:47:52 +03:00
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
2025-09-09 09:53:21 +03:00
with_submitter_timezone: false, with_signature_id_reason: true, with_file_links: nil)
2026-01-30 11:21:47 +02:00
cell_layouters = Hash.new do |hash, valign|
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
end
2024-10-15 19:16:09 +03:00
attachments_data_cache = {}
2025-08-31 16:40:18 +03:00
submission = submitter.submission
with_headings = find_last_submitter(submission, submitter:).blank? if with_headings.nil?
2024-12-23 22:58:24 +02:00
2025-06-03 09:52:46 +03:00
locale = submitter.metadata.fetch('lang', account.locale)
2025-09-26 20:52:44 +03:00
(submission.template_fields || submission.template.fields).each do |field|
next if !with_headings &&
(field['type'] == 'heading' || (field['type'] == 'strikethrough' && field['conditions'].blank?))
next if field['submitter_uuid'] != submitter.uuid && field['type'] != 'heading' &&
(field['type'] != 'strikethrough' || field['conditions'].present?)
2023-06-16 23:39:55 +03:00
2023-05-28 23:20:48 +03:00
field.fetch('areas', []).each do |area|
pdf = pdfs_index[area['attachment_uuid']]
2023-12-11 18:28:24 +02:00
next if pdf.nil?
2023-05-28 23:20:48 +03:00
page = pdf.pages[area['page']]
2023-12-11 18:28:24 +02:00
next if page.nil?
2023-09-06 17:27:54 +03:00
page.rotate(0, flatten: true) if page[:Rotate] != 0
2023-05-28 23:20:48 +03:00
2023-08-12 12:34:02 +03:00
page[:Annots] ||= []
2024-04-01 13:09:35 +03:00
page[:Annots] = page[:Annots].try(:reject) do |e|
2025-09-05 15:29:17 +03:00
next if e.is_a?(Integer) || e.is_a?(Symbol)
2024-04-01 13:09:35 +03:00
2023-12-06 16:04:28 +02:00
e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
2024-04-01 13:09:35 +03:00
end || page[:Annots]
2023-08-12 12:34:02 +03:00
2023-05-28 23:20:48 +03:00
width = page.box.width
height = page.box.height
2024-06-20 16:14:18 +03:00
2026-01-26 18:18:07 +02:00
preferences_font_size = field.dig('preferences', 'font_size').then { |num| num.presence&.to_i }
2024-06-20 16:14:18 +03:00
font_size = preferences_font_size
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
2023-05-28 23:20:48 +03:00
2025-05-02 12:33:27 +03:00
fill_color = field.dig('preferences', 'color').to_s.delete_prefix('#').presence
2025-10-29 10:10:04 +02:00
bg_color = field.dig('preferences', 'background').to_s.delete_prefix('#').presence
2024-11-10 12:48:53 +02:00
2025-02-12 19:49:50 +02:00
font_name = field.dig('preferences', 'font')
2025-03-05 12:12:16 +02:00
font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym
2025-02-12 19:49:50 +02:00
font_name = FONT_NAME unless font_name.in?(DEFAULT_FONTS)
2025-03-05 12:12:16 +02:00
if font_variant != :none && font_name == FONT_NAME
2025-06-27 14:45:29 +03:00
font_name = FONT_VARIANS[font_variant] if FONT_VARIANS[font_variant]
2025-03-05 12:12:16 +02:00
font_variant = nil unless font_name.in?(DEFAULT_FONTS)
end
2025-04-10 13:22:10 +03:00
font = pdf.fonts.add(font_name, variant: font_variant, custom_encoding: font_name.in?(DEFAULT_FONTS))
2024-07-16 13:18:03 +03:00
2023-06-13 00:29:07 +03:00
value = submitter.values[field['uuid']]
2024-12-23 22:58:24 +02:00
value = field['default_value'] if field['type'] == 'heading'
2025-09-26 20:52:44 +03:00
value = field['default_value'] if field['type'] == 'strikethrough' && value.nil? && field['conditions'].blank?
2023-05-28 23:20:48 +03:00
2024-03-17 19:06:00 +02:00
text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
(value.to_s.match?(RTL_REGEXP) ? :right : :left)
2025-01-24 23:08:44 +02:00
text_valign = (field.dig('preferences', 'valign').to_s.presence || 'center').to_sym
layouter = HexaPDF::Layout::TextLayouter.new(text_valign:, text_align:, font:, font_size:)
2024-01-07 14:19:21 +02:00
2023-07-08 20:51:37 +03:00
next if Array.wrap(value).compact_blank.blank?
2024-04-22 21:34:55 +03:00
if is_flatten
begin
page.flatten_annotations
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
end
2024-03-27 18:24:41 +02:00
end
2023-05-28 23:20:48 +03:00
canvas = page.canvas(type: :overlay)
2023-08-25 00:27:56 +03:00
canvas.font(FONT_NAME, size: font_size)
2023-05-28 23:20:48 +03:00
2025-06-26 21:44:05 +03:00
field_type = field['type']
field_type = 'file' if field_type == 'image' &&
!submitter.attachments.find { |a| a.uuid == value }.image?
2025-07-17 10:54:44 +03:00
if field_type == 'signature' && field.dig('preferences', 'with_signature_id').in?([true, false])
with_signature_id = field['preferences']['with_signature_id']
end
2025-10-29 10:10:04 +02:00
if bg_color.present?
canvas.fill_color(bg_color)
.rectangle(area['x'] * width, height - (area['y'] * height) - (area['h'] * height),
area['w'] * width, area['h'] * height)
.fill
end
2025-06-26 21:44:05 +03:00
case field_type
2024-08-06 00:19:33 +03:00
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
2024-07-24 20:55:35 +03:00
attachment = submitter.attachments.find { |a| a.uuid == value }
2025-06-19 11:57:48 +03:00
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
2024-07-24 20:55:35 +03:00
2024-08-06 00:19:33 +03:00
reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence
2024-07-24 20:55:35 +03:00
reason_string =
2025-06-03 09:52:46 +03:00
I18n.with_locale(locale) do
2025-07-10 20:47:52 +03:00
timezone = submitter.account.timezone
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
2026-01-19 13:56:17 +02:00
if with_signature_id_reason || field.dig('preferences', 'reasons').present?
2025-10-04 18:33:37 +03:00
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
2025-07-18 10:30:37 +03:00
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
else
2025-07-10 20:47:52 +03:00
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
2025-07-18 10:30:37 +03:00
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
end
2024-10-01 20:06:37 +03:00
end
2024-07-24 20:55:35 +03:00
2025-10-15 13:38:53 +03:00
base_font_size = (font_size / 1.8).to_i
result = nil
2024-07-24 20:55:35 +03:00
2025-12-20 17:12:15 +02:00
area_x = area['x'] * width
area_y = area['y'] * height
area_w = area['w'] * width
area_h = area['h'] * height
2024-07-24 20:55:35 +03:00
2025-12-20 17:12:15 +02:00
if area_h.positive? && (area_w.to_f / area_h) > 4.5
2025-05-18 09:44:55 +03:00
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
2024-07-24 20:55:35 +03:00
2025-05-18 09:44:55 +03:00
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
2024-07-24 20:55:35 +03:00
2025-05-18 09:44:55 +03:00
canvas.image(io, at: [image_x, image_y], width: image_width, height: image_height)
2024-07-24 20:55:35 +03:00
2025-05-18 09:44:55 +03:00
id_string = "ID: #{attachment.uuid}".upcase
2024-07-24 20:55:35 +03:00
2025-10-15 13:38:53 +03:00
loop do
text = HexaPDF::Layout::TextFragment.create(id_string, font:, font_size: base_font_size)
2024-07-24 20:55:35 +03:00
2025-10-15 13:38:53 +03:00
result = layouter.fit([text], half_width, base_font_size / 0.65)
2024-07-24 20:55:35 +03:00
2025-05-18 09:44:55 +03:00
break if result.status == :success
id_string = "#{id_string.delete_suffix('...')[0..-2]}..."
break if id_string.length < 8
end
2025-10-15 13:38:53 +03:00
string = [id_string, reason_string].join("\n")
loop do
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
2025-05-18 09:44:55 +03:00
2025-10-15 13:38:53 +03:00
result = layouter.fit([text], half_width, area_h)
2025-05-18 09:44:55 +03:00
2025-10-15 13:38:53 +03:00
break if result.status == :success
2025-05-18 09:44:55 +03:00
2025-10-15 13:38:53 +03:00
base_font_size *= 0.9
break if base_font_size < 2
end
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
text_x = area_x + half_width
text_y = height - area_y
layouter.fit([text], half_width, area_h).draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
2025-05-18 09:44:55 +03:00
else
2025-10-15 13:38:53 +03:00
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
font:,
font_size: base_font_size)
2025-05-18 09:44:55 +03:00
id_string = "ID: #{attachment.uuid}".upcase
loop do
text = HexaPDF::Layout::TextFragment.create(id_string,
font:,
2025-10-15 13:38:53 +03:00
font_size: base_font_size)
2025-05-18 09:44:55 +03:00
2025-12-20 17:12:15 +02:00
result = layouter.fit([text], area_w, base_font_size / 0.65)
2025-05-18 09:44:55 +03:00
break if result.status == :success
id_string = "#{id_string.delete_suffix('...')[0..-2]}..."
break if id_string.length < 8
end
2025-12-20 17:12:15 +02:00
reason_result = layouter.fit([reason_text], area_w, height)
2025-05-18 09:44:55 +03:00
text_height = result.lines.sum(&:height) + reason_result.lines.sum(&:height)
2025-12-20 17:12:15 +02:00
image_height = area_h - text_height
image_height = area_h / 2 if image_height < area_h / 2
2025-05-18 09:44:55 +03:00
2025-12-20 17:12:15 +02:00
scale = [area_w / image.width, image_height / image.height].min
2025-05-18 09:44:55 +03:00
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
2025-12-20 17:12:15 +02:00
layouter.fit([text], area_w, base_font_size / 0.65)
.draw(canvas, area_x + TEXT_LEFT_MARGIN,
height - area_y - TEXT_TOP_MARGIN - image_height)
2025-05-18 09:44:55 +03:00
2025-12-20 17:12:15 +02:00
layouter.fit([reason_text], area_w, reason_result.lines.sum(&:height))
.draw(canvas, area_x + TEXT_LEFT_MARGIN,
height - area_y - TEXT_TOP_MARGIN -
2025-05-18 09:44:55 +03:00
result.lines.sum(&:height) - image_height)
canvas.image(
io,
at: [
2025-12-20 17:12:15 +02:00
area_x + (area_w / 2) - ((image.width * scale) / 2),
height - area_y - (image.height * scale / 2) - (image_height / 2)
2025-05-18 09:44:55 +03:00
],
width: image.width * scale,
height: image.height * scale
)
end
2026-01-02 16:07:04 +02:00
when 'image', 'signature', 'initials', 'stamp', 'kba'
2023-06-14 02:08:26 +03:00
attachment = submitter.attachments.find { |a| a.uuid == value }
2023-06-24 22:50:12 +03:00
2024-11-01 18:39:03 +02:00
image =
begin
2025-05-19 14:06:37 +03:00
load_vips_image(attachment, attachments_data_cache).autorot
2024-11-01 18:39:03 +02:00
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
raise
end
2023-05-21 17:59:36 +03:00
2023-07-07 21:29:38 +03:00
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
2023-07-07 00:54:32 +03:00
2023-12-06 19:01:30 +02:00
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
2023-07-07 00:54:32 +03:00
2023-06-18 16:30:35 +03:00
canvas.image(
io,
at: [
2023-07-07 21:29:38 +03:00
(area['x'] * width) + (area['w'] * width / 2) - ((image.width * scale) / 2),
height - (area['y'] * height) - (image.height * scale / 2) - (area['h'] * height / 2)
2023-06-18 16:30:35 +03:00
],
2023-07-07 21:29:38 +03:00
width: image.width * scale,
height: image.height * scale
2023-06-18 16:30:35 +03:00
)
2023-12-14 21:22:26 +02:00
when 'file', 'payment'
2023-06-18 16:30:35 +03:00
items = Array.wrap(value).each_with_object([]) do |uuid, acc|
2023-06-14 02:08:26 +03:00
attachment = submitter.attachments.find { |a| a.uuid == uuid }
2023-05-21 17:59:36 +03:00
2023-08-09 09:33:14 +03:00
acc << HexaPDF::Layout::InlineBox.create(width: font_size, height: font_size,
2023-06-18 16:30:35 +03:00
margin: [0, 1, -2, 0]) do |cv, box|
cv.image(PdfIcons.paperclip_io, at: [0, 0], width: box.content_width)
end
2025-09-26 20:52:44 +03:00
acc << HexaPDF::Layout::TextFragment.create("#{attachment.filename}\n", font:, font_size:)
2023-05-28 23:20:48 +03:00
end
2023-06-18 16:30:35 +03:00
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 }
2024-04-23 20:09:39 +03:00
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)
2023-06-18 16:30:35 +03:00
2025-09-09 09:53:21 +03:00
url =
if with_file_links
ActiveStorage::Blob.proxy_url(attachment.blob)
else
r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
end
2023-06-18 16:30:35 +03:00
page[:Annots] << pdf.add(
{
Type: :Annot, Subtype: :Link,
Rect: [
2023-06-24 22:50:12 +03:00
(area['x'] * width) + TEXT_LEFT_MARGIN,
2024-04-23 20:09:39 +03:00
height - (area['y'] * height) - lines[...index].sum(&:height) +
height_diff - (height_diff.zero? ? diff : 0),
2023-06-24 22:50:12 +03:00
(area['x'] * width) + (area['w'] * width) + TEXT_LEFT_MARGIN,
2024-04-23 20:09:39 +03:00
height - (area['y'] * height) - lines[..next_index].sum(&:height) +
height_diff - (height_diff.zero? ? diff : 0)
2023-06-18 16:30:35 +03:00
],
2025-09-09 09:53:21 +03:00
A: { Type: :Action, S: :URI, URI: url }
2023-06-18 16:30:35 +03:00
}
)
acc + 1
2023-05-21 17:59:36 +03:00
end
2023-06-18 16:30:35 +03:00
layouter.fit(items, area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
2023-06-24 22:50:12 +03:00
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
2023-11-14 01:30:00 +02:00
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'] }
2025-12-12 09:10:44 +02:00
value =
if option
option_name = option['value'].presence
option_name ||= "#{I18n.t('option', locale: locale)} #{field['options'].index(option) + 1}"
2024-03-26 19:26:03 +02:00
2025-12-12 09:10:44 +02:00
Array.wrap(value).include?(option_name)
else
Rollbar.error("Invalid option: #{field['uuid']}") if defined?(Rollbar)
false
end
2023-11-14 01:30:00 +02:00
end
2023-06-18 16:30:35 +03:00
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
)
2024-09-21 12:49:52 +03:00
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
2023-06-27 00:57:29 +03:00
cell_width = area['cell_w'] * width
2026-01-30 11:21:47 +02:00
cell_valign = field.dig('preferences', 'valign').to_s.presence || 'center'
cell_layouter = cell_layouters[cell_valign]
2023-06-27 00:57:29 +03:00
2025-03-04 00:12:56 +02:00
if (mask = field.dig('preferences', 'mask').presence)
value = TextUtils.mask_value(value, mask)
end
2025-03-03 00:14:14 +02:00
2024-12-13 00:24:29 +02:00
chars = TextUtils.maybe_rtl_reverse(value).chars
chars = chars.reverse if field.dig('preferences', 'align') == 'right'
chars.each_with_index do |char, index|
2024-07-07 09:57:33 +03:00
next if char.blank?
2024-07-16 13:18:03 +03:00
text = HexaPDF::Layout::TextFragment.create(char, font:,
2024-11-10 12:48:53 +02:00
fill_color:,
2023-08-09 09:33:14 +03:00
font_size:)
2023-06-27 00:57:29 +03:00
2024-07-07 09:57:33 +03:00
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,
2024-07-16 13:18:03 +03:00
font:,
2024-11-10 12:48:53 +02:00
fill_color:,
2024-07-07 09:57:33 +03:00
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,
2024-07-16 13:18:03 +03:00
font:,
2024-11-10 12:48:53 +02:00
fill_color:,
2024-07-07 09:57:33 +03:00
font_size: (font_size / 1.9).to_i)
line_height = layouter.fit([text], cell_width, height).lines.first.height
end
2024-12-13 00:24:29 +02:00
x =
if field.dig('preferences', 'align') == 'right'
((area['x'] + area['w']) * width) - (cell_width * (index + 1))
else
(area['x'] * width) + (cell_width * index)
end
2024-07-07 09:57:33 +03:00
cell_layouter.fit([text], cell_width, [line_height, area['h'] * height].max)
2024-12-13 00:24:29 +02:00
.draw(canvas, x, height - (area['y'] * height))
2023-06-27 00:57:29 +03:00
end
2025-09-26 20:52:44 +03:00
when 'strikethrough'
scale = 1000.0 / width
line_width = 6.0 / scale
area_height = area['h'] * height
if area_height * scale < 40.0
canvas.tap do |c|
c.stroke_color(field.dig('preferences', 'color').presence || 'red')
c.line_width(line_width)
c.line(width * area['x'],
height - (height * area['y']) - (area_height / 2),
(width * area['x']) + (width * area['w']),
height - (height * area['y']) - (area_height / 2))
c.stroke
end
else
canvas.tap do |c|
c.stroke_color(field.dig('preferences', 'color').presence || 'red')
c.line_width(line_width)
c.line((width * area['x']) + (line_width / 2),
height - (height * area['y']) - (line_width / 2),
(width * area['x']) + (width * area['w']) - (line_width / 2),
height - (height * area['y']) - area_height + (line_width / 2))
c.stroke
end
canvas.tap do |c|
c.stroke_color(field.dig('preferences', 'color').presence || 'red')
c.line_width(line_width)
c.line((width * area['x']) + (line_width / 2),
height - (height * area['y']) - area_height + (line_width / 2),
(width * area['x']) + (width * area['w']) - (line_width / 2),
height - (height * area['y']) - (line_width / 2))
c.stroke
end
end
2023-05-28 23:20:48 +03:00
else
2023-12-17 23:32:05 +02:00
if field['type'] == 'date'
2025-06-03 09:52:46 +03:00
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), locale)
2023-12-17 23:32:05 +02:00
end
2023-06-18 16:30:35 +03:00
2024-05-12 11:12:07 +03:00
value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number'
2024-01-15 01:45:26 +02:00
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
2024-01-07 14:19:21 +02:00
2025-03-04 00:12:56 +02:00
if (mask = field.dig('preferences', 'mask').presence)
value = TextUtils.mask_value(value, mask)
end
2025-03-03 00:14:14 +02:00
2025-02-05 18:17:55 +02:00
text_params = { font:, fill_color:, font_size: }
2025-03-05 12:12:16 +02:00
text_params[:line_height] = text_params[:font_size] * (FONTS_LINE_HEIGHT[font_name] || 1)
2025-02-05 18:17:55 +02:00
2025-12-16 21:54:36 +02:00
text = HexaPDF::Layout::TextFragment.create(value.tr("\u00A0", ' '), **text_params)
2023-06-18 16:30:35 +03:00
lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height)
2023-11-07 11:36:11 +02:00
2024-06-20 16:14:18 +03:00
if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
2025-02-05 18:17:55 +02:00
text_params[:font_size] = (font_size / 1.4).to_i
2025-03-05 12:12:16 +02:00
text_params[:line_height] = text_params[:font_size] * (FONTS_LINE_HEIGHT[font_name] || 1)
2025-02-05 18:17:55 +02:00
text = HexaPDF::Layout::TextFragment.create(value, **text_params)
2023-11-07 11:36:11 +02:00
2024-03-17 19:06:00 +02:00
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
2023-11-07 11:36:11 +02:00
box_height = lines.sum(&:height)
end
2024-06-20 16:14:18 +03:00
if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
2025-02-05 18:17:55 +02:00
text_params[:font_size] = (font_size / 1.9).to_i
2025-03-05 12:12:16 +02:00
text_params[:line_height] = text_params[:font_size] * (FONTS_LINE_HEIGHT[font_name] || 1)
2025-02-05 18:17:55 +02:00
text = HexaPDF::Layout::TextFragment.create(value, **text_params)
2024-03-20 16:52:47 +02:00
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
box_height = lines.sum(&:height)
end
2023-06-18 16:30:35 +03:00
height_diff = [0, box_height - (area['h'] * height)].max
2024-03-17 19:06:00 +02:00
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
2025-01-24 23:08:44 +02:00
align_y_diff =
if text_valign == :top
0
elsif text_valign == :bottom
height_diff + TEXT_TOP_MARGIN
else
height_diff / 2
end
2024-03-17 19:06:00 +02:00
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,
2025-01-24 23:08:44 +02:00
height - (area['y'] * height) + align_y_diff - TEXT_TOP_MARGIN)
2023-05-21 17:59:36 +03:00
end
2023-05-28 23:20:48 +03:00
end
end
2024-10-15 20:49:19 +03:00
pdfs_index
2023-05-21 17:59:36 +03:00
end
2023-06-13 00:29:07 +03:00
2024-03-15 13:03:10 +02:00
def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
2023-06-22 01:31:39 +03:00
io = StringIO.new
2023-10-11 21:05:42 +03:00
pdf.trailer.info[:Creator] = info_creator
2023-06-24 12:27:34 +03:00
2025-07-10 12:13:27 +03:00
if Docuseal.pdf_format == 'pdf/a-3b'
pdf.task(:pdfa, level: '3b')
pdf.config['font.map'] = PDFA_FONT_MAP
end
2023-12-21 23:29:06 +02:00
sign_reason = fetch_sign_reason(submitter)
2023-12-02 18:14:33 +02:00
2024-12-17 23:25:04 +02:00
if sign_reason && pkcs
2023-12-21 23:29:06 +02:00
sign_params = {
reason: sign_reason,
2024-09-26 10:58:32 +03:00
**build_signing_params(submitter, pkcs, tsa_url)
2023-12-21 23:29:06 +02:00
}
2024-10-10 09:04:25 +03:00
pdf.pages.first[:Annots] = [] unless pdf.pages.first[:Annots].respond_to?(:<<)
2024-03-15 12:13:32 +02:00
begin
pdf.sign(io, write_options: { validate: false }, **sign_params)
2025-11-15 10:46:54 +02:00
rescue HexaPDF::Error, NoMethodError => e
2024-03-15 12:13:32 +02:00
Rollbar.error(e) if defined?(Rollbar)
2025-11-15 10:46:54 +02:00
begin
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
rescue HexaPDF::Error
pdf.validate(auto_correct: true)
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
end
2024-03-15 12:13:32 +02:00
end
2024-07-09 13:15:51 +03:00
maybe_enable_ltv(io, sign_params)
2023-12-21 23:29:06 +02:00
else
2024-03-15 12:13:32 +02:00
begin
pdf.write(io, incremental: true, validate: false)
2025-11-15 10:46:54 +02:00
rescue HexaPDF::Error, NoMethodError => e
2024-03-15 12:13:32 +02:00
Rollbar.error(e) if defined?(Rollbar)
2025-11-15 10:46:54 +02:00
begin
pdf.write(io, incremental: false, validate: false)
rescue HexaPDF::Error
pdf.validate(auto_correct: true)
pdf.write(io, incremental: false, validate: false)
end
2024-03-15 12:13:32 +02:00
end
2023-12-21 23:29:06 +02:00
end
2023-06-22 01:31:39 +03:00
2024-03-15 13:03:10 +02:00
ActiveStorage::Attachment.new(
2024-07-09 13:15:51 +03:00
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"),
2024-02-25 16:25:04 +02:00
metadata: { original_uuid: uuid,
analyzed: true,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
2023-06-22 01:31:39 +03:00
name: 'documents',
record: submitter
)
end
2024-07-09 13:15:51 +03:00
# rubocop:enable Metrics
def maybe_enable_ltv(io, _sign_params)
io
end
2023-06-22 01:31:39 +03:00
2024-09-26 10:58:32 +03:00
def build_signing_params(_submitter, pkcs, tsa_url)
2024-05-01 23:40:09 +03:00
params = {
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
}
if tsa_url
params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
2024-05-08 09:38:16 +03:00
params[:signature_size] = 20_000
2024-05-01 23:40:09 +03:00
end
params
end
2023-06-22 01:31:39 +03:00
def images_pdf_uuid(attachments)
Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':'))
end
2024-10-15 19:16:09 +03:00
def build_pdfs_index(submission, submitter: nil, flatten: true)
latest_submitter = find_last_submitter(submission, submitter:)
2023-07-01 13:16:07 +03:00
2026-01-28 08:50:44 +02:00
documents = Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents ||= submission.schema_documents
2023-06-13 00:29:07 +03:00
2026-01-28 08:50:44 +02:00
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
2023-06-13 00:29:07 +03:00
2024-12-07 01:35:59 +02:00
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]
2025-05-31 15:15:25 +03:00
attachment ||= submission.schema_documents.preload(:blob).find { |a| a.uuid == uuid }
2024-12-07 01:35:59 +02:00
next unless attachment
2023-06-22 01:31:39 +03:00
pdf =
if attachment.image?
build_pdf_from_image(attachment)
else
HexaPDF::Document.new(io: StringIO.new(attachment.download))
end
2024-04-22 19:10:58 +03:00
pdf = maybe_rotate_pdf(pdf)
2024-12-07 01:35:59 +02:00
maybe_flatten_pdf(pdf) if flatten
2024-02-08 17:59:40 +02:00
2024-03-15 19:02:17 +02:00
pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
2024-12-07 01:35:59 +02:00
acc[uuid] = pdf
2023-06-13 00:29:07 +03:00
end
end
2023-06-22 01:31:39 +03:00
2024-12-07 01:35:59 +02:00
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
2024-04-22 19:10:58 +03:00
def maybe_rotate_pdf(pdf)
return pdf if pdf.pages.size > MAX_PAGE_ROTATE
2025-08-28 17:43:39 +03:00
is_pages_rotated = pdf.pages.root[:Rotate].present? && pdf.pages.root[:Rotate] != 0
pdf.pages.root[:Rotate] = 0 if is_pages_rotated
2024-04-22 19:10:58 +03:00
is_rotated = pdf.pages.filter_map do |page|
page.rotate(0, flatten: true) if page[:Rotate] != 0
end.present?
2025-08-28 17:43:39 +03:00
return pdf if !is_rotated && !is_pages_rotated
2024-04-22 19:10:58 +03:00
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
2024-03-15 19:02:17 +02:00
def on_missing_glyph(character, font_wrapper)
2024-07-25 15:11:01 +03:00
Rails.logger.info("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
2024-03-15 19:02:17 +02:00
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
2024-10-15 19:16:09 +03:00
def find_last_submitter(submission, submitter: nil)
submission.submitters
.select(&:completed_at?)
2025-10-04 18:33:37 +03:00
.select { |e| submitter.nil? || (e.id != submitter.id && e.completed_at <= submitter.completed_at) }
2024-10-15 19:16:09 +03:00
.max_by(&:completed_at)
2024-02-25 16:25:04 +02:00
end
2023-06-22 01:31:39 +03:00
def build_pdf_from_image(attachment)
pdf = HexaPDF::Document.new
2024-05-02 13:40:01 +03:00
2023-06-22 01:31:39 +03:00
page = pdf.pages.add
2025-05-26 10:11:01 +03:00
image = attachment.preview_images.first
2023-06-22 01:31:39 +03:00
2025-05-26 10:11:01 +03:00
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
2023-06-22 01:31:39 +03:00
page.canvas.image(
2025-05-26 10:11:01 +03:00
StringIO.new(image.download),
2023-06-22 01:31:39 +03:00
at: [0, 0],
width: page.box.width,
height: page.box.height
)
pdf
end
2023-06-29 02:04:17 +03:00
2024-08-14 19:30:16 +03:00
def normalize_image_pdf(pdf)
io = StringIO.new
pdf.write(io)
io.rewind
HexaPDF::Document.new(io:)
end
2023-12-21 23:29:06 +02:00
def sign_reason(name)
format(SIGN_REASON, name:)
end
2024-05-16 23:18:48 +03:00
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(', '))
2023-12-21 23:29:06 +02:00
end
def fetch_sign_reason(submitter)
reason_name = submitter.email || submitter.name || submitter.phone
2023-12-25 13:16:52 +02:00
config =
if Docuseal.multitenant?
AccountConfig.where(account: submitter.account, key: AccountConfig::ESIGNING_PREFERENCE_KEY)
2024-05-16 23:18:48 +03:00
.first_or_initialize(value: 'single')
2023-12-25 13:16:52 +02:00
else
AccountConfig.where(key: AccountConfig::ESIGNING_PREFERENCE_KEY)
2024-05-16 23:18:48 +03:00
.first_or_initialize(value: 'single')
2023-12-25 13:16:52 +02:00
end
return sign_reason(reason_name) if config.value == 'multiple'
2023-12-21 23:29:06 +02:00
2024-05-16 23:18:48 +03:00
if !submitter.submission.submitters.exists?(completed_at: nil) &&
submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
return single_sign_reason(submitter)
end
2023-12-21 23:29:06 +02:00
nil
2023-10-11 21:05:42 +03:00
end
def info_creator
2023-12-30 01:19:46 +02:00
"#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})"
2023-10-11 21:05:42 +03:00
end
2024-12-01 00:10:07 +02:00
def detached_signature?(_submitter)
false
end
def generate_detached_signature_attachments(_submitter)
[]
end
2025-05-19 14:06:37 +03:00
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
2025-08-31 16:40:18 +03:00
def r
Rails.application.routes.url_helpers
end
2023-05-21 17:59:36 +03:00
end
end