From 033ce3a169f8def4bc9f0d73971215a4f5b12b19 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 2 Sep 2026 20:02:57 +0300 Subject: [PATCH 01/10] fix text align --- lib/submissions/generate_result_attachments.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 98d67c79..c221d38a 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -40,6 +40,8 @@ module Submissions RTL_REGEXP = TextUtils::RTL_REGEXP + TEXT_ALIGNS = %w[left center right justify].freeze + TEXT_VALIGNS = %w[top center bottom].freeze TEXT_LEFT_MARGIN = 1 TEXT_TOP_MARGIN = 1 MAX_PAGE_ROTATE = 50 @@ -280,10 +282,10 @@ module Submissions value = field['default_value'] if field['type'] == 'heading' value = field['default_value'] if field['type'] == 'strikethrough' && value.nil? && field['conditions'].blank? - text_align = field.dig('preferences', 'align').to_s.to_sym.presence || + text_align = field.dig('preferences', 'align').to_s.presence_in(TEXT_ALIGNS)&.to_sym || (value.to_s.match?(RTL_REGEXP) ? :right : :left) - text_valign = (field.dig('preferences', 'valign').to_s.presence || 'center').to_sym + text_valign = (field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center').to_sym layouter = HexaPDF::Layout::TextLayouter.new(text_valign:, text_align:, font:, font_size:) @@ -597,7 +599,7 @@ module Submissions ) when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? } cell_width = area['cell_w'] * width - cell_valign = field.dig('preferences', 'valign').to_s.presence || 'center' + cell_valign = field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center' cell_layouter = cell_layouters[cell_valign] if (mask = field.dig('preferences', 'mask').presence) From 436d947f6aa520eec7f2c1819056d62d3380ebfe Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 2 Sep 2026 20:08:42 +0300 Subject: [PATCH 02/10] detect fields autosave false --- .../templates_detect_fields_controller.rb | 10 ++++++++-- app/javascript/template_builder/fields.vue | 13 ++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/controllers/templates_detect_fields_controller.rb b/app/controllers/templates_detect_fields_controller.rb index a0cd54ff..d01925a1 100644 --- a/app/controllers/templates_detect_fields_controller.rb +++ b/app/controllers/templates_detect_fields_controller.rb @@ -10,8 +10,14 @@ class TemplatesDetectFieldsController < ApplicationController sse = SSE.new(response.stream) - documents = @template.schema_documents.preload(:blob) - documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present? + documents = + if params[:attachment_uuid].present? + @template.documents.where(uuid: params[:attachment_uuid]) + else + @template.schema_documents + end + + documents = documents.preload(:blob) page_number = params[:page].presence&.to_i diff --git a/app/javascript/template_builder/fields.vue b/app/javascript/template_builder/fields.vue index cab03639..29107103 100644 --- a/app/javascript/template_builder/fields.vue +++ b/app/javascript/template_builder/fields.vue @@ -555,8 +555,10 @@ export default { return this.template.schema.some((item) => item.dynamic) }, numberOfPages () { - return this.template.documents.reduce((acc, doc) => { - return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length + return this.template.schema.reduce((acc, item) => { + const doc = this.template.documents.find((d) => d.uuid === item.attachment_uuid) + + return acc + (doc?.metadata?.pdf?.number_of_pages || doc?.preview_images?.length || 0) }, 0) }, isShowFieldSearch () { @@ -774,9 +776,10 @@ export default { headers: { 'Content-Type': 'application/json' }, - ...(this.withDetectExistingFields - ? { body: JSON.stringify({ fields: this.buildExistingFields() }) } - : {}) + body: JSON.stringify({ + attachment_uuid: this.template.schema.map((item) => item.attachment_uuid), + ...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {}) + }) }).then(async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') From 785eb789ad0fc46f354b23f03c5aac7e3ef08867 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 2 Sep 2026 20:42:08 +0300 Subject: [PATCH 03/10] fix undo autosave false --- app/javascript/template_builder/builder.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index a834e373..f0146f39 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -3525,6 +3525,8 @@ export default { this.onChange(this.template) } + this.pushUndo() + if (!this.autosave && !force) { return Promise.resolve({}) } @@ -3535,8 +3537,6 @@ export default { } }) - this.pushUndo() - return this.baseFetch(`/templates/${this.template.id}`, { method: 'PUT', body: JSON.stringify({ From f9669b0e16130e14ec7676154242a19a9a74a004 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 3 Sep 2026 11:48:54 +0300 Subject: [PATCH 04/10] process pdf v2 --- app/controllers/templates_debug_controller.rb | 7 +- lib/pdf_utils.rb | 18 -- lib/templates/build_annotations.rb | 40 --- lib/templates/create_attachments.rb | 31 -- lib/templates/find_acro_fields.rb | 288 ------------------ lib/templates/find_pdfium_acro_fields.rb | 37 ++- lib/templates/modify_documents.rb | 8 +- lib/templates/process_document.rb | 82 +---- spec/factories/templates.rb | 6 +- 9 files changed, 59 insertions(+), 458 deletions(-) delete mode 100644 lib/templates/build_annotations.rb delete mode 100644 lib/templates/find_acro_fields.rb diff --git a/app/controllers/templates_debug_controller.rb b/app/controllers/templates_debug_controller.rb index 333e8847..551d167f 100644 --- a/app/controllers/templates_debug_controller.rb +++ b/app/controllers/templates_debug_controller.rb @@ -13,9 +13,10 @@ class TemplatesDebugController < ApplicationController data = attachment.download unless attachment.image? - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - fields = Templates::FindAcroFields.call(pdf, attachment, data) + fields = + Pdfium::Document.open_io(StringIO.new(data)) do |doc| + Templates::FindPdfiumAcroFields.call(attachment, doc, data) + end end # fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank? diff --git a/lib/pdf_utils.rb b/lib/pdf_utils.rb index 71595479..1e7c55a2 100644 --- a/lib/pdf_utils.rb +++ b/lib/pdf_utils.rb @@ -6,24 +6,6 @@ module PdfUtils module_function - def encrypted?(data, password: nil) - HexaPDF::Document.new(io: StringIO.new(data), decryption_opts: { password: }) - - false - rescue HexaPDF::EncryptionError - true - end - - def decrypt(data, password) - decrypted_io = StringIO.new - - Pdfium::Document.open_bytes(data, password) do |doc| - doc.save(decrypted_io, flags: Pdfium::FPDF_REMOVE_SECURITY) - end - - decrypted_io.tap(&:rewind).read - end - def merge(io_files) merged_content = StringIO.new diff --git a/lib/templates/build_annotations.rb b/lib/templates/build_annotations.rb deleted file mode 100644 index 8ce566d1..00000000 --- a/lib/templates/build_annotations.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Templates - module BuildAnnotations - module_function - - def call(data) - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - pdf.pages.flat_map.with_index do |page, index| - (page[:Annots] || []).filter_map do |annot| - next if annot.blank? - next if annot.is_a?(Integer) || annot.is_a?(Symbol) || annot.is_a?(HexaPDF::PDFArray) - next if annot[:A].blank? || annot[:A][:URI].blank? - next unless annot[:Subtype] == :Link - next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://') - - build_external_link_hash(page, annot).merge('page' => index) - end - end - rescue StandardError => e - Rollbar.error(e) if defined?(Rollbar) - - [] - end - - def build_external_link_hash(page, annot) - left, bottom, right, top = annot[:Rect] - - { - 'type' => 'external_link', - 'value' => annot[:A][:URI], - 'x' => left / page.box.width.to_f, - 'y' => (page.box.height - top) / page.box.height.to_f, - 'w' => (right - left) / page.box.width.to_f, - 'h' => (top - bottom) / page.box.height.to_f - } - end - end -end diff --git a/lib/templates/create_attachments.rb b/lib/templates/create_attachments.rb index 8b3a555f..f5b47fd9 100644 --- a/lib/templates/create_attachments.rb +++ b/lib/templates/create_attachments.rb @@ -39,23 +39,6 @@ module Templates end def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {}) - return handle_pdf_or_image_v2(template, file, document_data, params, extract_fields:, metadata:) if v2? - - document_data ||= file.read - - if file.content_type == PDF_CONTENT_TYPE - document_data = maybe_decrypt_pdf_or_raise(document_data, params) - - annotations = - document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(document_data) : [] - end - - document = create_document(template, file, document_data, metadata, annotations) - - Templates::ProcessDocument.call(document, document_data, extract_fields:) - end - - def handle_pdf_or_image_v2(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {}) document_data ||= file.read unless file.content_type == PDF_CONTENT_TYPE @@ -80,16 +63,6 @@ module Templates doc&.close end - def maybe_decrypt_pdf_or_raise(data, params) - if data.size < ANNOTATIONS_SIZE_LIMIT && PdfUtils.encrypted?(data) - PdfUtils.decrypt(data, params[:password]) - else - data - end - rescue Pdfium::PasswordError - raise PdfEncrypted - end - def decrypt_document(doc) io = StringIO.new @@ -163,9 +136,5 @@ module Templates raise InvalidFileType, "#{file.content_type}/#{dynamic}" end - - def v2? - true - end end end diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb deleted file mode 100644 index 6da0bf72..00000000 --- a/lib/templates/find_acro_fields.rb +++ /dev/null @@ -1,288 +0,0 @@ -# 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.to_f / 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.presence - } - 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.presence - } - elsif field.field_type == :Tx && field.concrete_field_type == :comb_text_field - { - **attrs, - type: 'cells', - default_value: field.field_value.presence - } - 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.presence - } - else - { - **attrs, - type: 'text', - default_value: field.field_value.presence - } - 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 - - if option.is_a?(String) || option.is_a?(Symbol) - option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '') - end - - next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP) - - { - uuid: SecureRandom.uuid, - value: is_option_number || is_skip_single_value ? '' : option.presence - } - 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] - seen = Set.new.compare_by_identity - - field = field[:Parent] while field[:Parent] && seen.add?(field.value) - - 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 = [], seen = Set.new.compare_by_identity) - 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 - - next unless seen.add?(field.value) - - if field.terminal_field? - acc << field - else - process_fields_array(pdf, field[:Kids], acc, seen) - end - end - - acc - end - # rubocop:enable Metrics - end -end diff --git a/lib/templates/find_pdfium_acro_fields.rb b/lib/templates/find_pdfium_acro_fields.rb index d9133aaa..805ed75c 100644 --- a/lib/templates/find_pdfium_acro_fields.rb +++ b/lib/templates/find_pdfium_acro_fields.rb @@ -6,6 +6,31 @@ module Templates SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze TEXT_OPERATOR_REGEXP = /\bT[jJ]\b/ + 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 def call(attachment, doc, data) @@ -108,12 +133,12 @@ module Templates def build_field_properties(widgets) field = widgets.first.field - field_name = field.name if field.name.match?(FindAcroFields::FIELD_NAME_REGEXP) + field_name = field.name if field.name.match?(FIELD_NAME_REGEXP) attrs = { name: field_name.to_s } attrs[:description] = field.alternate_name if field.alternate_name.present? && field.alternate_name != field.name && - !field.alternate_name.in?(FindAcroFields::SKIP_FIELD_DESCRIPTION) + !field.alternate_name.in?(SKIP_FIELD_DESCRIPTION) case field.type when :checkbox, :radio @@ -172,7 +197,7 @@ module Templates **attrs, type: 'select', options: build_options(field.options, 'select'), - default_value: value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : value + default_value: value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : value } end @@ -183,7 +208,7 @@ module Templates end def build_text_properties(attrs, field) - preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') } + preferences = { align: FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') } attrs = { **attrs, preferences: } @@ -191,7 +216,7 @@ module Templates { **attrs, type: 'cells', default_value: field.value.presence } elsif date?(field) format = [field.format_js, field.keystroke_js].compact - .filter_map { |js| js[FindAcroFields::DATE_FORMAT_REGEXP] } + .filter_map { |js| js[DATE_FORMAT_REGEXP] } .first preferences[:format] = format.upcase if format @@ -218,7 +243,7 @@ module Templates option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '') end - next if type == 'select' && option.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) + next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP) { uuid: SecureRandom.uuid, diff --git a/lib/templates/modify_documents.rb b/lib/templates/modify_documents.rb index 8136520e..5be3b07b 100644 --- a/lib/templates/modify_documents.rb +++ b/lib/templates/modify_documents.rb @@ -435,7 +435,9 @@ module Templates end def save_document(template, old_attachment, data) - annotations = data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(data) : [] + doc = Pdfium::Document.open_io(StringIO.new(data)) + + annotations = data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildPdfiumAnnotations.call(doc) : [] sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(data)) blob = ActiveStorage::Blob.create_and_upload!( @@ -448,7 +450,9 @@ module Templates document = template.documents.create!(blob:) - Templates::ProcessDocument.call(document, data) + Templates::ProcessDocument.call(document, data, doc:) + ensure + doc&.close end def remap_fields(template, mapping) diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index 2d60665e..e8f138f3 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -22,16 +22,10 @@ module Templates def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil) if attachment.content_type == PDF_CONTENT_TYPE if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE - if doc - fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) - else - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - fields = Templates::FindAcroFields.call(pdf, attachment, data) - end + fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) end - generate_pdf_preview_images(attachment, data, pdf, max_pages:, doc:) + generate_pdf_preview_images(attachment, data, max_pages:, doc:) attachment.metadata['pdf']['fields'] = fields if fields elsif attachment.image? @@ -41,27 +35,13 @@ module Templates attachment end - def process(attachment, data, extract_fields: false, doc: nil) + def process(attachment, data, doc:, extract_fields: false) if attachment.content_type == PDF_CONTENT_TYPE && extract_fields && data.size < MAX_FLATTEN_FILE_SIZE - if doc - fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) - else - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - fields = Templates::FindAcroFields.call(pdf, attachment, data) - end - end - - if doc - number_of_pages = doc.page_count - else - pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) - - number_of_pages = pdf.pages.size + fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) end attachment.metadata['pdf'] ||= {} - attachment.metadata['pdf']['number_of_pages'] = number_of_pages + attachment.metadata['pdf']['number_of_pages'] = doc.page_count attachment.metadata['pdf']['fields'] = fields if fields attachment @@ -88,17 +68,10 @@ module Templates ) end - def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil) + def generate_pdf_preview_images(attachment, data, doc:, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all - if doc - number_of_pages = doc.page_count - else - pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) - number_of_pages = pdf.pages.size - - data = maybe_flatten_form(data, pdf) - end + number_of_pages = doc.page_count attachment.metadata['pdf'] ||= {} attachment.metadata['pdf']['number_of_pages'] = number_of_pages @@ -109,20 +82,19 @@ module Templates max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1 - generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min, doc:) + generate_document_preview_images(attachment, 0..[number_of_pages - 1, max_pages_to_process].min, doc:) end - def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil) - flatten_pages = doc&.form? - pdfium_doc = doc || Pdfium::Document.open_bytes(data) + def generate_document_preview_images(attachment, range, doc:, concurrency: CONCURRENCY) + flatten_pages = doc.form? pool = Concurrent::FixedThreadPool.new(concurrency) promises = range.map do |page_number| - doc_page = pdfium_doc.get_page(page_number) + doc_page = doc.get_page(page_number) - hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) if doc + hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) doc_page.flatten if flatten_pages bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH) @@ -146,7 +118,6 @@ module Templates end end ensure - pdfium_doc&.close if doc.nil? pool&.kill end @@ -162,7 +133,7 @@ module Templates if value.blank? next unless hide_empty elsif handle.option_labels.blank? || - !value.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) + !value.match?(FindPdfiumAcroFields::SELECT_PLACEHOLDER_REGEXP) next end @@ -194,33 +165,6 @@ module Templates blob end - def maybe_flatten_form(data, pdf) - return data if data.size > MAX_FLATTEN_FILE_SIZE - return data if pdf.acro_form.blank? - - io = StringIO.new - - pdf.acro_form.each_field do |field| - next if field.field_type != :Ch || - field[:Opt].blank? || - %i[combo_box editable_combo_box].exclude?(field.concrete_field_type) || - !field.field_value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) - - field[:V] = '' - end - - pdf.acro_form.create_appearances(force: true) if pdf.acro_form[:NeedAppearances] - pdf.acro_form.flatten - - pdf.write(io, incremental: false, validate: false) - - io.string - rescue StandardError - raise if Rails.env.development? - - data - end - def normalize_attachment_fields(template, attachments = template.documents) attachments.flat_map do |a| pdf_fields = a.metadata['pdf'].delete('fields').to_a if a.metadata['pdf'].present? diff --git a/spec/factories/templates.rb b/spec/factories/templates.rb index 33988385..09d5a3fd 100644 --- a/spec/factories/templates.rb +++ b/spec/factories/templates.rb @@ -42,7 +42,11 @@ FactoryBot.define do record: template ) - Templates::ProcessDocument.call(attachment, attachment.download) + data = attachment.download + + Pdfium::Document.open_io(StringIO.new(data)) do |doc| + Templates::ProcessDocument.call(attachment, data, doc:) + end template.schema << { attachment_uuid: attachment.uuid, From e316d2006fe1978ee0c3e5265ac27dd8b70d7115 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 3 Sep 2026 18:44:54 +0300 Subject: [PATCH 05/10] subject only email message --- app/models/email_message.rb | 2 ++ lib/params/submission_create_validator.rb | 6 ------ lib/submitters.rb | 2 +- spec/requests/submissions_spec.rb | 11 ++++++++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/models/email_message.rb b/app/models/email_message.rb index 5fdc1021..e2411e1c 100644 --- a/app/models/email_message.rb +++ b/app/models/email_message.rb @@ -31,6 +31,8 @@ class EmailMessage < ApplicationRecord attribute :uuid, :string, default: -> { SecureRandom.uuid } + normalizes :body, with: ->(value) { value.to_s }, apply_to_nil: true + before_validation :set_sha1, on: :create def normalized_body diff --git a/lib/params/submission_create_validator.rb b/lib/params/submission_create_validator.rb index 523dd092..c33ed88a 100644 --- a/lib/params/submission_create_validator.rb +++ b/lib/params/submission_create_validator.rb @@ -29,8 +29,6 @@ module Params in_path(params, :message) do |message_params| type(message_params, :subject, String) type(message_params, :body, String) - - required(message_params, :body) end end @@ -53,8 +51,6 @@ module Params in_path(params, :message, skip_blank: true) do |message_params| type(message_params, :subject, String) type(message_params, :body, String) - - required(message_params, :body) end value_in(params, :order, %w[preserved random], allow_nil: true) @@ -119,8 +115,6 @@ module Params in_path(params, :message) do |message_params| type(message_params, :subject, String) type(message_params, :body, String) - - required(message_params, :body) end value_in(params, :order, %w[preserved random], allow_nil: true) diff --git a/lib/submitters.rb b/lib/submitters.rb index 71041a3d..6a562390 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -143,7 +143,7 @@ module Submitters def normalize_preferences(account, user, params) preferences = {} - message_params = params['message'].presence || params.slice('subject', 'body').presence + message_params = (params['message'].presence || params.slice('subject', 'body')).compact_blank if message_params.present? email_message = EmailMessages.find_or_create_for_account_user(account, user, diff --git a/spec/requests/submissions_spec.rb b/spec/requests/submissions_spec.rb index 3adf6092..6dacc802 100644 --- a/spec/requests/submissions_spec.rb +++ b/spec/requests/submissions_spec.rb @@ -197,7 +197,7 @@ describe 'Submission API' do expect(response.parsed_body).to eq({ 'error' => 'Defined more signing parties than in template' }) end - it 'returns an error if the message has no body value' do + it 'creates a submission when the message has only a subject' do post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: { template_id: templates[0].id, send_email: true, @@ -209,8 +209,13 @@ describe 'Submission API' do } }.to_json - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body).to eq({ 'error' => 'body is required in `message`.' }) + expect(response).to have_http_status(:ok) + + submission = Submission.last + email_message = EmailMessage.last + + expect(submission.submitters.first.preferences['email_message_uuid']).to eq(email_message.uuid) + expect(email_message).to have_attributes(subject: 'Custom Email Subject', body: '') end end From 9ad281d457e23255489b1dcf9284be8eb346dca8 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 4 Sep 2026 09:01:51 +0300 Subject: [PATCH 06/10] handle 2fa email bounce --- app/controllers/start_form_controller.rb | 4 +++ .../start_form_email_2fa_send_controller.rb | 6 ++++ .../start_form_resubmit_controller.rb | 4 +++ .../submit_form_email_2fas_controller.rb | 6 ++++ app/views/start_form/error.html.erb | 31 +++++++++++++++++++ config/locales/i18n.yml | 21 +++++++++++++ lib/submitters.rb | 12 +++++++ 7 files changed, 84 insertions(+) create mode 100644 app/views/start_form/error.html.erb diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index a5b6fce1..86471d38 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -162,6 +162,10 @@ class StartFormController < ApplicationController end rescue Submitters::StartForm::NotSaved render :show, status: :unprocessable_content + rescue Submitters::BouncedEmail => e + @error_message = e.message + + render :error, status: :unprocessable_content rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)), alert: e.message diff --git a/app/controllers/start_form_email_2fa_send_controller.rb b/app/controllers/start_form_email_2fa_send_controller.rb index 07dd18c4..0787ab46 100644 --- a/app/controllers/start_form_email_2fa_send_controller.rb +++ b/app/controllers/start_form_email_2fa_send_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class StartFormEmail2faSendController < ApplicationController + layout 'form' + around_action :with_browser_locale skip_before_action :authenticate_user! @@ -23,6 +25,10 @@ class StartFormEmail2faSendController < ApplicationController redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), **redir_params + rescue Submitters::BouncedEmail => e + @error_message = e.message + + render 'start_form/error', status: :unprocessable_content rescue Submitters::UnableToSendCode => e redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), alert: e.message diff --git a/app/controllers/start_form_resubmit_controller.rb b/app/controllers/start_form_resubmit_controller.rb index d0c91c69..d616a4e0 100644 --- a/app/controllers/start_form_resubmit_controller.rb +++ b/app/controllers/start_form_resubmit_controller.rb @@ -87,6 +87,10 @@ class StartFormResubmitController < ApplicationController end rescue Submitters::StartForm::NotSaved render 'start_form/show', status: :unprocessable_content + rescue Submitters::BouncedEmail => e + @error_message = e.message + + render 'start_form/error', status: :unprocessable_content rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e flash.now[:alert] = e.message diff --git a/app/controllers/submit_form_email_2fas_controller.rb b/app/controllers/submit_form_email_2fas_controller.rb index dfe518b0..377f99ca 100644 --- a/app/controllers/submit_form_email_2fas_controller.rb +++ b/app/controllers/submit_form_email_2fas_controller.rb @@ -35,6 +35,12 @@ class SubmitFormEmail2fasController < ApplicationController RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) + if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email) + Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar) + + return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:verification_email_bounced) + end + SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s) redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {} diff --git a/app/views/start_form/error.html.erb b/app/views/start_form/error.html.erb new file mode 100644 index 00000000..45221209 --- /dev/null +++ b/app/views/start_form/error.html.erb @@ -0,0 +1,31 @@ +<% content_for(:html_title, "#{@template.name} | DocuSeal") %> +<% I18n.with_locale(@template.account.locale) do %> + <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> +<% end %> +
+
+
+
+ <%= render 'start_form/banner' %> +
+
+
+
+ <%= svg_icon('writing_sign', class: 'w-10 h-10') %> +
+
+

<%= @template.name %>

+

<%= t('invited_by_html', name: @template.account.name) %>

+
+
+
+
+
+ <%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %> +
<%= @error_message %>
+
+
+ <%= link_to t('back'), @resubmit_submitter ? submit_form_path(@resubmit_submitter.slug) : start_form_path(@template.slug, @submitter.slice(:name, :email, :phone).compact_blank), class: 'base-button' %> +
+
+
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 6d5dfe53..869795e2 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -790,6 +790,7 @@ en: &en the_code_has_been_sent_to_your_email: The code has been sent to your email. enter_the_verification_code_from_your_email: Enter the verification code from your email. too_many_attempts: Too many attempts. + verification_email_bounced: "Unable to send the verification code: email to this address bounced." verification_code: Verification Code resend_code: Resend Code verify_new_sign_in: Verify new sign in @@ -1916,6 +1917,7 @@ es: &es the_code_has_been_sent_to_your_email: El código ha sido enviado a tu correo electrónico. enter_the_verification_code_from_your_email: Ingresa el código de verificación de tu correo electrónico. too_many_attempts: Demasiados intentos. + verification_email_bounced: "No se pudo enviar el código de verificación: el correo a esta dirección rebotó." verification_code: Código de Verificación resend_code: Reenviar Código verify_new_sign_in: Verificar nuevo inicio de sesión @@ -3049,6 +3051,7 @@ it: &it the_code_has_been_sent_to_your_email: Il codice è stato inviato alla tua e-mail. enter_the_verification_code_from_your_email: Inserisci il codice di verifica dalla tua e-mail. too_many_attempts: Troppi tentativi. + verification_email_bounced: "Impossibile inviare il codice di verifica: l'email a questo indirizzo è stata respinta." verification_code: Codice di Verifica resend_code: Reinvia Codice verify_new_sign_in: Verifica nuovo accesso @@ -4168,6 +4171,7 @@ fr: &fr the_code_has_been_sent_to_your_email: Le code a été envoyé à votre e‑mail. enter_the_verification_code_from_your_email: Saisissez le code de vérification reçu par e‑mail. too_many_attempts: Trop de tentatives. + verification_email_bounced: "Impossible d'envoyer le code de vérification : l'e-mail à cette adresse a été rejeté." verification_code: Code de vérification resend_code: Renvoyer le code verify_new_sign_in: Vérifier une nouvelle connexion @@ -5297,6 +5301,7 @@ pt: &pt the_code_has_been_sent_to_your_email: O código foi enviado para seu e-mail. enter_the_verification_code_from_your_email: Insira o código de verificação do seu e-mail. too_many_attempts: Muitas tentativas. + verification_email_bounced: "Não foi possível enviar o código de verificação: o e-mail para este endereço não foi entregue." verification_code: Código de Verificação resend_code: Reenviar Código verify_new_sign_in: Verificar novo login @@ -6429,6 +6434,7 @@ de: &de the_code_has_been_sent_to_your_email: Der Code wurde an Ihre E-Mail gesendet. enter_the_verification_code_from_your_email: Geben Sie den Verifizierungscode aus Ihrer E-Mail ein. too_many_attempts: Zu viele Versuche. + verification_email_bounced: "Der Verifizierungscode konnte nicht gesendet werden: E-Mail an diese Adresse ist unzustellbar." verification_code: Verifizierungscode resend_code: Code erneut senden verify_new_sign_in: Neue Anmeldung verifizieren @@ -6868,6 +6874,8 @@ pl: the_code_has_been_sent_to_your_email: Kod został wysłany na Twój e-mail. enter_the_verification_code_from_your_email: Wprowadź kod weryfikacyjny z Twojego e-maila. too_many_attempts: Zbyt wiele prób. + verification_email_bounced: "Nie można wysłać kodu weryfikacyjnego: e-mail na ten adres został odrzucony." + back: Wstecz verification_code: Kod Weryfikacyjny resend_code: Wyślij Kod Ponownie powered_by: 'Napędzany przez' @@ -7008,6 +7016,8 @@ uk: the_code_has_been_sent_to_your_email: Код було надіслано на вашу електронну пошту. enter_the_verification_code_from_your_email: Введіть код перевірки з вашої електронної пошти. too_many_attempts: Забагато спроб. + verification_email_bounced: "Не вдалося надіслати код підтвердження: лист на цю адресу було відхилено." + back: Назад verification_code: Код перевірки resend_code: Надіслати код знову powered_by: 'Працює на базі' @@ -7148,6 +7158,8 @@ cs: the_code_has_been_sent_to_your_email: Kód byl odeslán na váš e-mail. enter_the_verification_code_from_your_email: Zadejte ověřovací kód z vašeho e-mailu. too_many_attempts: Příliš mnoho pokusů. + verification_email_bounced: "Ověřovací kód nelze odeslat: e-mail na tuto adresu byl odmítnut." + back: Zpět verification_code: Ověřovací kód resend_code: Znovu odeslat kód powered_by: 'Poháněno' @@ -7274,6 +7286,8 @@ he: the_code_has_been_sent_to_your_email: הקוד נשלח לדוא"ל שלך. enter_the_verification_code_from_your_email: הזן את קוד האימות מדוא"ל שלך. too_many_attempts: יותר מדי ניסיונות. + verification_email_bounced: "לא ניתן לשלוח את קוד האימות: האימייל לכתובת זו נדחה." + back: חזרה verification_code: קוד אימות resend_code: שלח קוד מחדש powered_by: 'מופעל על ידי' @@ -8081,6 +8095,7 @@ nl: &nl the_code_has_been_sent_to_your_email: De code is naar uw e-mail verzonden. enter_the_verification_code_from_your_email: Voer de verificatiecode uit uw e-mail in. too_many_attempts: Te veel pogingen. + verification_email_bounced: "Verificatiecode kan niet worden verzonden: e-mail naar dit adres is geretourneerd." verification_code: Verificatiecode resend_code: Code opnieuw verzenden verify_new_sign_in: Nieuwe aanmelding verifiëren @@ -8503,6 +8518,8 @@ ar: the_code_has_been_sent_to_your_email: تم إرسال الرمز إلى بريدك الإلكتروني. enter_the_verification_code_from_your_email: أدخل رمز التحقق من بريدك الإلكتروني. too_many_attempts: عدد المحاولات كبير جدًا. + verification_email_bounced: "تعذر إرسال رمز التحقق: تم رفض البريد الإلكتروني المرسل إلى هذا العنوان." + back: رجوع verification_code: رمز التحقق resend_code: إعادة إرسال الرمز powered_by: 'مشغل بواسطة' @@ -8615,6 +8632,8 @@ ko: the_code_has_been_sent_to_your_email: 코드가 이메일로 전송되었습니다. enter_the_verification_code_from_your_email: 이메일로 받은 인증 코드를 입력하세요. too_many_attempts: 시도 횟수가 너무 많습니다. + verification_email_bounced: "인증 코드를 보낼 수 없습니다: 이 주소로 보낸 이메일이 반송되었습니다." + back: 뒤로 verification_code: 인증 코드 resend_code: 코드 재전송 powered_by: '제공:' @@ -8727,6 +8746,8 @@ ja: the_code_has_been_sent_to_your_email: コードがあなたのメールに送信されました enter_the_verification_code_from_your_email: メールに記載された認証コードを入力してください too_many_attempts: 試行回数が多すぎます + verification_email_bounced: "認証コードを送信できません:このアドレス宛のメールが配信できませんでした。" + back: 戻る verification_code: 認証コード resend_code: コードを再送信 powered_by: '提供元:' diff --git a/lib/submitters.rb b/lib/submitters.rb index 6a562390..3ea1aa07 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -11,6 +11,7 @@ module Submitters }.freeze UnableToSendCode = Class.new(StandardError) + BouncedEmail = Class.new(UnableToSendCode) InvalidOtp = Class.new(StandardError) MaliciousFileExtension = Class.new(StandardError) ParamsError = Class.new(StandardError) @@ -243,6 +244,12 @@ module Submitters def send_shared_link_email_verification_code(submitter, request:) RateLimit.call("send-otp-code-#{request.remote_ip}", limit: 2, ttl: 45.seconds, enabled: true) + if Docuseal.multitenant? && email_bounced_recently?(submitter.email) + Rollbar.warning("Bounced OTP email for template: #{submitter.submission.template.id}") if defined?(Rollbar) + + raise BouncedEmail, I18n.t(:verification_email_bounced) + end + TemplateMailer.otp_verification_email(submitter.submission.template, email: submitter.email).deliver_later! rescue RateLimit::LimitApproached Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar) @@ -250,6 +257,11 @@ module Submitters raise UnableToSendCode, I18n.t('too_many_attempts') end + def email_bounced_recently?(email) + EmailEvent.exists?(email:, event_type: %w[bounce soft_bounce permanent_bounce], + event_datetime: 24.hours.ago..Time.current) + end + def verify_link_otp!(otp, submitter) return false if otp.blank? From 887d80f6d7079fc1a0c35028fadc42d613a7ed9b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 5 Sep 2026 13:09:09 +0300 Subject: [PATCH 07/10] raw page size --- lib/pdfium.rb | 28 ++++++++++++++++++++++++++++ lib/templates/modify_documents.rb | 5 +---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/pdfium.rb b/lib/pdfium.rb index 470c2ccf..f5d46396 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -105,6 +105,7 @@ class Pdfium attach_function :FPDF_LoadCustomDocument, %i[pointer FPDF_STRING], :FPDF_DOCUMENT attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int + attach_function :FPDF_GetPageSizeByIndexF, %i[FPDF_DOCUMENT int pointer], :int attach_function :FPDF_GetLastError, [], :ulong attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int @@ -282,6 +283,7 @@ class Pdfium attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int attach_function :FPDFPage_GetAnnotCountRaw, %i[FPDF_DOCUMENT int], :int + attach_function :FPDFPage_GetRotationRaw, %i[FPDF_DOCUMENT int], :int attach_function :FPDFPage_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int @@ -368,6 +370,11 @@ class Pdfium :bottom, :float end + class FS_SIZEF < FFI::Struct + layout :width, :float, + :height, :float + end + class FS_MATRIX < FFI::Struct layout :a, :float, :b, :float, @@ -531,6 +538,8 @@ class Pdfium @pages = {} @annot_counts = {} + @page_sizes = {} + @page_rotations = {} @closed = false @source_buffer = source_buffer @form_handle = FFI::Pointer::NULL @@ -555,6 +564,23 @@ class Pdfium @page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr) end + def page_size(page_index) + @page_sizes[page_index] ||= + begin + size = Pdfium::FS_SIZEF.new + + [size[:width], size[:height]] if Pdfium.FPDF_GetPageSizeByIndexF(@document_ptr, page_index, size) == 1 + end + end + + def reset_page_size(page_index) + @page_sizes.delete(page_index) + end + + def page_rotation(page_index) + @page_rotations[page_index] ||= Pdfium.FPDFPage_GetRotationRaw(@document_ptr, page_index) + end + def encrypted? Pdfium.FPDF_GetSecurityHandlerRevision(@document_ptr) >= 0 end @@ -983,6 +1009,8 @@ class Pdfium def rotation=(value) Pdfium.FPDFPage_SetRotation(@page_ptr, value) + @document.reset_page_size(@page_index) + @rotation = value end diff --git a/lib/templates/modify_documents.rb b/lib/templates/modify_documents.rb index 5be3b07b..d10cd97d 100644 --- a/lib/templates/modify_documents.rb +++ b/lib/templates/modify_documents.rb @@ -345,10 +345,7 @@ module Templates uuid = pdf_ref['attachment_uuid'] source = sources[[uuid, nil]] ||= open_or_build_pdf(attachments_index[uuid]) - page = source.get_page(pdf_ref['page']) - - width = page.width - height = page.height + width, height = source.page_size(pdf_ref['page']) width, height = height, width unless (pdf_ref['rotate'].to_i % 180).zero? From ec04a95e3655b2ac82655755b6f4423dfe3dc675 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 6 Sep 2026 09:39:53 +0300 Subject: [PATCH 08/10] fix pdf validation --- lib/pdfium.rb | 4 ++-- lib/verify_pdf_signature.rb | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/pdfium.rb b/lib/pdfium.rb index f5d46396..6724b232 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -2306,14 +2306,14 @@ class Pdfium [PageObject, *to_a].hash end - private - def object_ptr page.ensure_not_closed! Pdfium.FPDFPage_GetObject(page.page_ptr, index) end + private + def read_bounds buffer = Array.new(4) { FFI::MemoryPointer.new(:float) } diff --git a/lib/verify_pdf_signature.rb b/lib/verify_pdf_signature.rb index ec588e5e..f8da1408 100644 --- a/lib/verify_pdf_signature.rb +++ b/lib/verify_pdf_signature.rb @@ -173,7 +173,7 @@ module VerifyPdfSignature io.seek(0) Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document| - next false unless signed_document.valid_cross_reference_table? + next true unless signed_document.valid_cross_reference_table? serialized_document(signed_document) != serialized_document(document) end @@ -182,13 +182,20 @@ module VerifyPdfSignature def serialized_document(document) pages = (0...document.page_count).map do |index| page = document.get_page(index) + objects = page.objects.map { |object| [*object.to_a, image_digest(page, object)] } - [page.rotation, page.objects, page.annotations, page.text] + [page.rotation, objects, page.annotations, page.text] end [pages, document.bookmarks] end + def image_digest(page, object) + return unless object.image? + + Digest::SHA256.hexdigest(page.extract_image_bitmap(object.object_ptr)[:data]) + end + def signed_data(io, byte_range) byte_range.each_slice(2).map do |offset, length| io.seek(offset) From e0b0d4e8035664f85a2fa4319fbc19381f72bd5d Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 6 Sep 2026 18:04:16 +0300 Subject: [PATCH 09/10] add authorize --- app/controllers/accounts_controller.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 162d553c..37591527 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -23,17 +23,20 @@ class AccountsController < ApplicationController unless Docuseal.multitenant? @encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) - @encrypted_config.assign_attributes(app_url_params) - unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS]) - @encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url')) + if can?(:manage, @encrypted_config) + @encrypted_config.assign_attributes(app_url_params) - return render :show, status: :unprocessable_content - end + unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS]) + @encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url')) + + return render :show, status: :unprocessable_content + end - @encrypted_config.save! + @encrypted_config.save! - Docuseal.refresh_default_url_options! + Docuseal.refresh_default_url_options! + end end with_locale do From 7b4c74d7111ddd4ec111beb3715a79d90321028a Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 6 Sep 2026 18:50:33 +0300 Subject: [PATCH 10/10] safe page cycle --- config/initializers/hexapdf.rb | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/config/initializers/hexapdf.rb b/config/initializers/hexapdf.rb index a8dd5203..f639374f 100644 --- a/config/initializers/hexapdf.rb +++ b/config/initializers/hexapdf.rb @@ -46,6 +46,20 @@ module HexaPDF def color_space(name) GlobalConfiguration.constantize('color_space.map', name).new end + + def [](name) + return super unless value[name].nil? && INHERITABLE_FIELDS.include?(name) + + seen = Set.new.compare_by_identity + seen << value + node = self + + while node.value[name].nil? && (parent = node[:Parent]) && seen.add?(parent.value) + node = parent + end + + node == self || node.value[name].nil? ? super : node[name] + end end # fix NoMethodError: undefined method `field_value' for #