From e87c06d811a70dcab2017497c765df042d184811 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 11 Aug 2026 17:23:28 +0300 Subject: [PATCH 01/18] uniq bcc addresses --- app/jobs/process_submitter_completion_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 897ff350..50375483 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -143,7 +143,7 @@ class ProcessSubmitterCompletionJob end def enqueue_bcc_completed_emails(submitter, user, is_sent_to_user) - bcc_addresses = build_bcc_addresses(submitter.submission) + bcc_addresses = build_bcc_addresses(submitter.submission).uniq raise TooManyBcc, submitter.account_id if Docuseal.multitenant? && bcc_addresses.size > BCC_LIMIT From 90be34b243cf7e0978f34159212c9d89a6e709d1 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 11 Aug 2026 22:05:14 +0300 Subject: [PATCH 02/18] update gem --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0d7b7624..d320a892 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -271,7 +271,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.21.1) + json (2.21.2) jwt (3.2.0) base64 language_server-protocol (3.17.0.5) From 726d0ea4f3c3db12d52fdf8baa949abbb33fca2c Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 13 Aug 2026 15:46:23 +0300 Subject: [PATCH 03/18] validate url --- app/controllers/templates_uploads_controller.rb | 4 +++- app/views/submissions_filters/_filter_modal.html.erb | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index ea4febf0..d389bfe3 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -6,7 +6,9 @@ class TemplatesUploadsController < ApplicationController layout 'plain' def show - redirect_to root_path if params[:url].blank? + url_uri = Addressable::URI.parse(params[:url].to_s) + + redirect_to root_path if url_uri.normalized_scheme != 'https' || url_uri.host.blank? end def create diff --git a/app/views/submissions_filters/_filter_modal.html.erb b/app/views/submissions_filters/_filter_modal.html.erb index 8794197a..b37a5dfb 100644 --- a/app/views/submissions_filters/_filter_modal.html.erb +++ b/app/views/submissions_filters/_filter_modal.html.erb @@ -1,5 +1,7 @@ +<% path_uri = Addressable::URI.parse(params[:path].to_s) %> +<% path = url_for(**Rails.application.routes.recognize_path(path_uri.path, method: :get)) %> <%= render 'shared/turbo_modal', title: local_assigns[:title] do %> - <%= form_for '', url: params[:path], method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> + <%= form_for '', url: path, method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> <%= hidden_field_tag :q, params[:q] if params[:q].present? %> <% local_assigns[:default_params].each do |key, value| %> <%= hidden_field_tag(key, value) if value.present? %> @@ -10,7 +12,7 @@ <% if params[:with_remove] %>
- <%= link_to t('remove_filter'), "#{params[:path]}?#{request.query_parameters.slice('q').merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %> + <%= link_to t('remove_filter'), "#{path}?#{request.query_parameters.slice('q').merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %>
<% end %> <% end %> From e867bf5c485cb75e23c28500751d6a2470332daa Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 14 Aug 2026 12:16:25 +0300 Subject: [PATCH 04/18] use json serializer --- config/application.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/application.rb b/config/application.rb index 356cc169..95dbb4c2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -19,6 +19,8 @@ module DocuSeal class Application < Rails::Application config.load_defaults 8.1 + config.active_support.message_serializer = :json + config.autoload_lib(ignore: %w[assets tasks puma]) config.active_storage.routes_prefix = '' From e361cbabf42cf80288a5796abe91fd8cca9cb4d6 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 12 Aug 2026 11:15:43 +0300 Subject: [PATCH 05/18] adjust verify signature --- .../verify_pdf_signature_controller.rb | 14 +- .../verify_pdf_signature/_result.html.erb | 39 ++-- config/locales/i18n.yml | 21 ++ lib/pdfium.rb | 197 ++++++++++++++++++ lib/verify_pdf_signature.rb | 170 +++++++++++++++ 5 files changed, 408 insertions(+), 33 deletions(-) create mode 100644 lib/verify_pdf_signature.rb diff --git a/app/controllers/verify_pdf_signature_controller.rb b/app/controllers/verify_pdf_signature_controller.rb index a81a6da8..d1af8e97 100644 --- a/app/controllers/verify_pdf_signature_controller.rb +++ b/app/controllers/verify_pdf_signature_controller.rb @@ -4,16 +4,16 @@ class VerifyPdfSignatureController < ApplicationController skip_authorization_check def create - pdfs = - params[:files].map do |file| - HexaPDF::Document.new(io: file.open) - end - trusted_certs = Accounts.load_trusted_certs(current_account) + signatures = + params[:files].map do |file| + VerifyPdfSignature.call(file.open, trusted_certs) + end + render turbo_stream: turbo_stream.replace('result', partial: 'result', - locals: { pdfs:, files: params[:files], trusted_certs: }) - rescue HexaPDF::MalformedPDFError + locals: { signatures:, files: params[:files] }) + rescue Pdfium::PdfiumError render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result')) end end diff --git a/app/views/verify_pdf_signature/_result.html.erb b/app/views/verify_pdf_signature/_result.html.erb index 6f44c2da..73dabae2 100644 --- a/app/views/verify_pdf_signature/_result.html.erb +++ b/app/views/verify_pdf_signature/_result.html.erb @@ -1,7 +1,7 @@
- <% files.zip(pdfs).each do |file, pdf| %> + <% files.zip(signatures).each do |file, file_signatures| %>
- <% if pdf.signatures.to_a.size == 0 %> + <% if file_signatures.size == 0 %>
<%= file.original_filename %>
@@ -11,42 +11,29 @@ <% else %>
<%= svg_icon('file_text', class: 'w-5 h-5 inline') %> - <%= file.original_filename %> - <%= pluralize(pdf.signatures.to_a.size, 'Signature') %> + <%= file.original_filename %> - <%= pluralize(file_signatures.size, 'Signature') %>
- <% pdf.signatures.to_a.each do |signature| %> + <% file_signatures.each do |signature| %>
- <% signature.verify(trusted_certs:).messages.map(&:content).each do |message| %> + <% signature.messages.each do |message| %>

- <% if message == 'Signature verification failed' %> + <% if message.status == :error %> <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> - <% elsif message == 'Signature valid' %> + <% elsif message.status == :success %> <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %> + <% elsif message.status == :warning %> + <%= svg_icon('alert_circle', class: 'w-6 h-6 text-yellow-500') %> <% end %> - <%= message %> + <%= message.text %>

- <% if message == 'Signature valid' %> -

- <% if trusted_certs.any? { |e| e.public_key.to_der == signature.signature_handler.signer_certificate.public_key.to_der } %> - <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %> - - <%= t('signed_with_trusted_certificate') %> - - <% else %> - <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> - - <%= t('signed_with_external_certificate') %> - - <% end %> -

- <% end %> <% end %>
<%= svg_icon('user', class: 'w-5 h-5 inline') %> - <%= signature.signing_reason %> + <%= signature.reason %>
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %> @@ -54,11 +41,11 @@
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %> - <%= signature.signer_name.force_encoding('UTF-8') %> + <%= signature.common_name %>
<%= svg_icon('lock_access', class: 'w-5 h-5 inline') %> - <%= signature.signature_type %> + <%= signature.type %>
<% end %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index a14d53aa..830a4731 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -596,6 +596,9 @@ en: &en there_are_no_signatures: There are no signatures signed_with_trusted_certificate: Signed with trusted certificate signed_with_external_certificate: Signed with external certificate + signature_valid: Signature valid + signature_verification_failed: Signature verification failed + contains_unsigned_changes_after_the_last_signature: Contains unsigned changes after the last signature setup_2fa_to_continue: Setup 2FA to continue. create_a_new_account: Create a new account. you_are_already_signed_in: You are already signed in. @@ -1706,6 +1709,9 @@ es: &es there_are_no_signatures: No hay firmas signed_with_trusted_certificate: Firmado con certificado de confianza signed_with_external_certificate: Firmado con certificado externo + signature_valid: Firma válida + signature_verification_failed: Verificación de firma fallida + contains_unsigned_changes_after_the_last_signature: Contiene cambios no firmados después de la última firma setup_2fa_to_continue: Configura 2FA para continuar. create_a_new_account: Crear una nueva cuenta. you_are_already_signed_in: Ya has iniciado sesión. @@ -2827,6 +2833,9 @@ it: &it there_are_no_signatures: Non ci sono firme signed_with_trusted_certificate: Firmato con certificato affidabile signed_with_external_certificate: Firmato con certificato esterno + signature_valid: Firma valida + signature_verification_failed: Verifica della firma non riuscita + contains_unsigned_changes_after_the_last_signature: Contiene modifiche non firmate dopo l’ultima firma setup_2fa_to_continue: Configura 2FA per continuare. create_a_new_account: Crea un nuovo account. you_are_already_signed_in: "Hai già effettuato l'accesso." @@ -3934,6 +3943,9 @@ fr: &fr there_are_no_signatures: Il n’y a pas de signatures signed_with_trusted_certificate: Signé avec un certificat de confiance signed_with_external_certificate: Signé avec un certificat externe + signature_valid: Signature valide + signature_verification_failed: Échec de la vérification de la signature + contains_unsigned_changes_after_the_last_signature: Contient des modifications non signées après la dernière signature setup_2fa_to_continue: Configurez la 2FA pour continuer. create_a_new_account: Créer un nouveau compte. you_are_already_signed_in: Vous êtes déjà connecté. @@ -5051,6 +5063,9 @@ pt: &pt there_are_no_signatures: Não há assinaturas signed_with_trusted_certificate: Assinado com certificado confiável signed_with_external_certificate: Assinado com certificado externo + signature_valid: Assinatura válida + signature_verification_failed: Falha na verificação da assinatura + contains_unsigned_changes_after_the_last_signature: Contém alterações não assinadas após a última assinatura setup_2fa_to_continue: Configure 2FA para continuar. create_a_new_account: Criar uma nova conta. you_are_already_signed_in: Você já está conectado. @@ -6171,6 +6186,9 @@ de: &de there_are_no_signatures: Es gibt keine Unterschriften signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat signed_with_external_certificate: Signiert mit externem Zertifikat + signature_valid: Signatur gültig + signature_verification_failed: Signaturprüfung fehlgeschlagen + contains_unsigned_changes_after_the_last_signature: Enthält nicht signierte Änderungen nach der letzten Signatur setup_2fa_to_continue: Richten Sie 2FA ein, um fortzufahren. create_a_new_account: Neues Konto erstellen. you_are_already_signed_in: Sie sind bereits angemeldet. @@ -7807,6 +7825,9 @@ nl: &nl there_are_no_signatures: Er zijn geen handtekeningen signed_with_trusted_certificate: Ondertekend met vertrouwd certificaat signed_with_external_certificate: Ondertekend met extern certificaat + signature_valid: Handtekening geldig + signature_verification_failed: Verificatie van handtekening mislukt + contains_unsigned_changes_after_the_last_signature: Bevat niet-ondertekende wijzigingen na de laatste handtekening setup_2fa_to_continue: Stel 2FA in om door te gaan. create_a_new_account: Maak een nieuw account aan. you_are_already_signed_in: U bent al aangemeld. diff --git a/lib/pdfium.rb b/lib/pdfium.rb index e7c5ec40..a2ae95c0 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -28,6 +28,10 @@ class Pdfium typedef :pointer, :FPDF_PAGEOBJECT typedef :pointer, :FPDF_PATHSEGMENT typedef :pointer, :FPDF_FONT + typedef :pointer, :FPDF_SIGNATURE + typedef :pointer, :FPDF_ANNOTATION + typedef :pointer, :FPDF_BOOKMARK + typedef :pointer, :FPDF_DEST MAX_SIZE = 32_767 @@ -102,6 +106,31 @@ class Pdfium attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int attach_function :FPDF_GetLastError, [], :ulong + attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong + attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int + + attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :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 + attach_function :FPDFAnnot_GetRect, %i[FPDF_ANNOTATION pointer], :int + + attach_function :FPDFBookmark_GetFirstChild, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK + attach_function :FPDFBookmark_GetNextSibling, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK + attach_function :FPDFBookmark_GetTitle, %i[FPDF_BOOKMARK pointer ulong], :ulong + attach_function :FPDFBookmark_GetDest, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_DEST + attach_function :FPDFDest_GetDestPageIndex, %i[FPDF_DOCUMENT FPDF_DEST], :int + attach_function :FPDFDest_GetLocationInPage, + %i[FPDF_DEST pointer pointer pointer pointer pointer pointer], :int + + attach_function :FPDF_GetSignatureCount, [:FPDF_DOCUMENT], :int + attach_function :FPDF_GetSignatureObject, %i[FPDF_DOCUMENT int], :FPDF_SIGNATURE + attach_function :FPDFSignatureObj_GetContents, %i[FPDF_SIGNATURE pointer ulong], :ulong + attach_function :FPDFSignatureObj_GetByteRange, %i[FPDF_SIGNATURE pointer ulong], :ulong + attach_function :FPDFSignatureObj_GetSubFilter, %i[FPDF_SIGNATURE pointer ulong], :ulong + attach_function :FPDFSignatureObj_GetReason, %i[FPDF_SIGNATURE pointer ulong], :ulong + attach_function :FPDFSignatureObj_GetTime, %i[FPDF_SIGNATURE pointer ulong], :ulong + attach_function :FPDFSignatureObj_GetDocMDPPermission, [:FPDF_SIGNATURE], :uint attach_function :FPDF_LoadPage, %i[FPDF_DOCUMENT int], :FPDF_PAGE attach_function :FPDF_ClosePage, [:FPDF_PAGE], :void @@ -253,6 +282,13 @@ class Pdfium FLATTEN_NOTHINGTODO = 2 # rubocop:disable Naming/ClassAndModuleCamelCase + class FS_RECTF < FFI::Struct + layout :left, :float, + :top, :float, + :right, :float, + :bottom, :float + end + class FS_MATRIX < FFI::Struct layout :a, :float, :b, :float, @@ -519,6 +555,72 @@ class Pdfium @pages[page_index] ||= Page.new(self, page_index) end + def bookmarks(parent = nil, seen = Set.new) + acc = [] + bookmark = Pdfium.FPDFBookmark_GetFirstChild(@document_ptr, parent) + + until bookmark.null? + break unless seen.add?(bookmark.address) + + acc << [bookmark_title(bookmark), *destination(Pdfium.FPDFBookmark_GetDest(@document_ptr, bookmark))] + acc.concat(bookmarks(bookmark, seen)) + + bookmark = Pdfium.FPDFBookmark_GetNextSibling(@document_ptr, bookmark) + end + + acc + end + + def bookmark_title(bookmark) + length = Pdfium.FPDFBookmark_GetTitle(bookmark, nil, 0) + + return if length.zero? + + buffer = FFI::MemoryPointer.new(:char, length) + Pdfium.FPDFBookmark_GetTitle(bookmark, buffer, length) + + buffer.read_bytes(length).force_encoding('UTF-16LE').encode('UTF-8').delete("\u0000") + end + + def destination(dest) + return [] if dest.nil? || dest.null? + + flags = Array.new(3) { FFI::MemoryPointer.new(:int) } + coords = Array.new(3) { FFI::MemoryPointer.new(:float) } + Pdfium.FPDFDest_GetLocationInPage(dest, *flags, *coords) + + [Pdfium.FPDFDest_GetDestPageIndex(@document_ptr, dest), + *coords.each_with_index.map { |c, i| c.read_float.round(3) if flags[i].read_int == 1 }] + end + + def valid_cross_reference_table? + Pdfium.FPDF_DocumentHasValidCrossReferenceTable(@document_ptr) == 1 + end + + def signature_count + @signature_count ||= Pdfium.FPDF_GetSignatureCount(@document_ptr) + end + + def signatures + @signatures ||= (0...signature_count).map { |index| Signature.new(self, index) } + end + + def trailer_ends + @trailer_ends ||= + begin + count = Pdfium.FPDF_GetTrailerEnds(@document_ptr, nil, 0) + + if count.zero? + [] + else + buffer = FFI::MemoryPointer.new(:uint, count) + Pdfium.FPDF_GetTrailerEnds(@document_ptr, buffer, count) + + buffer.read_array_of_uint(count) + end + end + end + def save(io, flags: Pdfium::FPDF_NO_INCREMENTAL) ensure_not_closed! @@ -589,7 +691,74 @@ class Pdfium end end + class Signature + attr_reader :document, :index, :signature_ptr + + def initialize(document, index) + @document = document + @index = index + @signature_ptr = Pdfium.FPDF_GetSignatureObject(document.document_ptr, index) + + raise PdfiumError, "Failed to load signature #{index}, pointer is NULL." if @signature_ptr.null? + end + + def byte_range + @byte_range ||= + begin + count = Pdfium.FPDFSignatureObj_GetByteRange(signature_ptr, nil, 0) + buffer = FFI::MemoryPointer.new(:int, count) + Pdfium.FPDFSignatureObj_GetByteRange(signature_ptr, buffer, count) + + buffer.read_array_of_int(count) + end + end + + def signed_end + @signed_end ||= byte_range.last(2).sum + end + + def contents + @contents ||= read_bytes(:FPDFSignatureObj_GetContents) + end + + def sub_filter + @sub_filter ||= read_bytes(:FPDFSignatureObj_GetSubFilter).to_s.delete("\u0000") + end + + def time + @time ||= read_bytes(:FPDFSignatureObj_GetTime).to_s.delete("\u0000") + end + + def reason + @reason ||= + begin + bytes = read_bytes(:FPDFSignatureObj_GetReason) + + bytes&.force_encoding('UTF-16LE')&.encode('UTF-8').to_s.delete("\u0000").presence + end + end + + def doc_mdp_permission + @doc_mdp_permission ||= Pdfium.FPDFSignatureObj_GetDocMDPPermission(signature_ptr) + end + + private + + def read_bytes(function) + length = Pdfium.public_send(function, signature_ptr, nil, 0) + + return if length.zero? + + buffer = FFI::MemoryPointer.new(:char, length) + Pdfium.public_send(function, signature_ptr, buffer, length) + + buffer.read_bytes(length) + end + end + class Page + RECT_KEYS = %i[left top right bottom].freeze + attr_reader :document, :page_index, :page_ptr def initialize(document, page_index) @@ -619,6 +788,34 @@ class Pdfium @height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr) end + def annotations + (0...Pdfium.FPDFPage_GetAnnotCount(page_ptr)).filter_map do |index| + annotation = Pdfium.FPDFPage_GetAnnot(page_ptr, index) + + next if annotation.null? + + begin + rect = Pdfium::FS_RECTF.new + Pdfium.FPDFAnnot_GetRect(annotation, rect) + + [Pdfium.FPDFAnnot_GetSubtype(annotation), + *RECT_KEYS.map { |key| rect[key].round(3) }] + ensure + Pdfium.FPDFPage_CloseAnnot(annotation) + end + end + end + + def objects + (0...Pdfium.FPDFPage_CountObjects(page_ptr)).map do |index| + object = Pdfium.FPDFPage_GetObject(page_ptr, index) + bounds = Array.new(4) { FFI::MemoryPointer.new(:float) } + Pdfium.FPDFPageObj_GetBounds(object, *bounds) + + [Pdfium.FPDFPageObj_GetType(object), *bounds.map { |b| b.read_float.round(3) }] + end + end + def rotation @rotation ||= Pdfium.FPDFPage_GetRotation(@page_ptr) end diff --git a/lib/verify_pdf_signature.rb b/lib/verify_pdf_signature.rb new file mode 100644 index 00000000..22717759 --- /dev/null +++ b/lib/verify_pdf_signature.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module VerifyPdfSignature + COMMON_NAME = 'CN' + TIME_FORMAT = '%Y%m%d%H%M%S%z' + + SignatureStruct = Struct.new(:messages, :reason, :signing_time, :common_name, :type) + MessageStruct = Struct.new(:text, :status) + + module_function + + def call(io, trusted_certs) + Pdfium::Document.open_io(io) do |document| + signatures = document.signatures.select { |e| e.byte_range.any?(&:positive?) && e.contents.present? } + + next [] if signatures.blank? + + has_unsigned_changes = unsigned_changes?(document, io) + + signatures.map.with_index do |signature, index| + build_signature(signature, io, trusted_certs, + has_unsigned_changes && index == signatures.size - 1) + end + end + end + + def build_signature(signature, io, trusted_certs, has_unsigned_changes) + pkcs7 = OpenSSL::PKCS7.new(signature.contents) + verified = verify_contents(pkcs7, signed_data(io, signature.byte_range), trusted_certs) + + SignatureStruct.new( + messages: build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes), + reason: signature.reason, + signing_time: signing_time(pkcs7, signature), + common_name: common_name(pkcs7), + type: signature.sub_filter + ) + rescue OpenSSL::PKCS7::PKCS7Error + SignatureStruct.new( + messages: [MessageStruct.new(text: I18n.t('signature_verification_failed'), status: :error)], + reason: signature.reason, + signing_time: parse_time(signature.time), + type: signature.sub_filter + ) + end + + def build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes) + messages = + if verified + [MessageStruct.new(text: I18n.t('signature_valid'), status: :success), + certificate_message(pkcs7, trusted_certs)] + else + [MessageStruct.new(text: I18n.t('signature_verification_failed'), status: :error)] + end + + if has_unsigned_changes + messages << MessageStruct.new(text: I18n.t('contains_unsigned_changes_after_the_last_signature'), + status: :warning) + end + + messages << MessageStruct.new(text: "Certificate chain: #{certificate_chain(pkcs7).join(' -> ')}") + end + + def certificate_message(pkcs7, trusted_certs) + public_key = signer_certificate(pkcs7)&.public_key&.to_der + + if trusted_certs.any? { |e| e.public_key.to_der == public_key } + MessageStruct.new(text: I18n.t('signed_with_trusted_certificate'), status: :success) + else + MessageStruct.new(text: I18n.t('signed_with_external_certificate'), status: :error) + end + end + + def verify_contents(pkcs7, signed_data, trusted_certs) + return false if digest_algorithms(pkcs7).blank? + + store = OpenSSL::X509::Store.new + store.set_default_paths + store.purpose = OpenSSL::X509::PURPOSE_SMIME_SIGN + store.verify_callback = ->(_success, _context) { true } + trusted_certs.each { |cert| store.add_cert(cert) } + + pkcs7.verify(pkcs7.certificates, store, signed_data, + OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY) + end + + def digest_algorithms(pkcs7) + OpenSSL::ASN1.decode(pkcs7.to_der).value[1].value[0].value[1].value + end + + def common_name(pkcs7) + cert = signer_certificate(pkcs7) + + return if cert.nil? + + cert.subject.to_a.assoc(COMMON_NAME)&.dig(1) + end + + def signer_certificate(pkcs7) + info = pkcs7.signers.first + + pkcs7.certificates&.find { |cert| cert.issuer == info.issuer && cert.serial == info.serial } + end + + def certificate_chain(pkcs7) + signer = signer_certificate(pkcs7) + + return [] if signer.nil? + + certs = [signer] + + while (issuer = pkcs7.certificates.find { |cert| cert.subject == certs.last.issuer }) + break if certs.include?(issuer) + + certs << issuer + end + + certs.map { |cert| cert.subject.to_a.assoc(COMMON_NAME)&.dig(1) } + end + + def signing_time(pkcs7, signature) + cms_signing_time(pkcs7) || parse_time(signature.time) + end + + def cms_signing_time(pkcs7) + pkcs7.signers.first&.signed_time + rescue StandardError + nil + end + + def parse_time(value) + return if value.blank? + + time = value.delete("'").delete_prefix('D:') + offset = time[14..].to_s + + Time.strptime("#{time.first(14)}#{offset.start_with?('+', '-') ? offset : '+0000'}", TIME_FORMAT) + end + + def unsigned_changes?(document, io) + signed_end = document.signatures.map(&:signed_end).max + + return false if document.trailer_ends.none? { |offset| offset > signed_end } + + io.seek(0) + + Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document| + next false unless signed_document.valid_cross_reference_table? + + serialized_document(signed_document) != serialized_document(document) + end + end + + def serialized_document(document) + pages = (0...document.page_count).map do |index| + page = document.get_page(index) + + [page.rotation, page.objects, page.annotations, page.text] + end + + [pages, document.bookmarks] + end + + def signed_data(io, byte_range) + byte_range.each_slice(2).map do |offset, length| + io.seek(offset) + io.read(length) + end.join + end +end From 185827c9518c0736bb86d2ffd0d332bb3483c5ae Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Tue, 11 Aug 2026 23:25:45 +0300 Subject: [PATCH 06/18] add Pdfium acro fields detector --- lib/pdfium.rb | 642 ++++++++++++++++++++-- lib/templates/build_pdfium_annotations.rb | 47 ++ lib/templates/find_pdfium_acro_fields.rb | 211 +++++++ 3 files changed, 869 insertions(+), 31 deletions(-) create mode 100644 lib/templates/build_pdfium_annotations.rb create mode 100644 lib/templates/find_pdfium_acro_fields.rb diff --git a/lib/pdfium.rb b/lib/pdfium.rb index a2ae95c0..d7fad6ef 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -109,12 +109,6 @@ class Pdfium attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int - attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :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 - attach_function :FPDFAnnot_GetRect, %i[FPDF_ANNOTATION pointer], :int - attach_function :FPDFBookmark_GetFirstChild, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK attach_function :FPDFBookmark_GetNextSibling, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK attach_function :FPDFBookmark_GetTitle, %i[FPDF_BOOKMARK pointer ulong], :ulong @@ -219,6 +213,14 @@ class Pdfium FPDF_PAGEOBJ_SHADING = 4 FPDF_PAGEOBJ_FORM = 5 + PAGE_OBJECT_TYPES = { + FPDF_PAGEOBJ_TEXT => :text, + FPDF_PAGEOBJ_PATH => :path, + FPDF_PAGEOBJ_IMAGE => :image, + FPDF_PAGEOBJ_SHADING => :shading, + FPDF_PAGEOBJ_FORM => :form + }.freeze + # Path segment types FPDF_SEGMENT_UNKNOWN = -1 FPDF_SEGMENT_LINETO = 0 @@ -272,6 +274,73 @@ class Pdfium attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void + attach_function :FORM_OnAfterLoadPage, %i[FPDF_PAGE FPDF_FORMHANDLE], :void + attach_function :FORM_OnBeforeClosePage, %i[FPDF_PAGE FPDF_FORMHANDLE], :void + + attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int + + begin + attach_function :FPDFPage_GetAnnotCountRaw, %i[FPDF_DOCUMENT int], :int + rescue FFI::NotFoundError + define_singleton_method(:FPDFPage_GetAnnotCountRaw) { |*| -1 } # rubocop:disable Naming/MethodName + end + + attach_function :FPDFPage_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION + attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void + attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetRect, %i[FPDF_ANNOTATION pointer], :int + attach_function :FPDFAnnot_GetNumberValue, %i[FPDF_ANNOTATION string pointer], :int + attach_function :FPDFAnnot_GetStringValue, %i[FPDF_ANNOTATION string pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormFieldType, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetFormFieldFlags, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetFormFieldName, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormFieldAlternateName, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormFieldValue, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormFieldExportValue, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormControlCount, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetFormControlIndex, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetOptionCount, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetOptionLabel, %i[FPDF_FORMHANDLE FPDF_ANNOTATION int pointer ulong], :ulong + attach_function :FPDFAnnot_IsChecked, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_GetFormAdditionalActionJavaScript, + %i[FPDF_FORMHANDLE FPDF_ANNOTATION int pointer ulong], :ulong + + FPDF_ANNOT_LINK = 2 + FPDF_ANNOT_WIDGET = 20 + + FPDF_FORMFIELD_UNKNOWN = 0 + FPDF_FORMFIELD_PUSHBUTTON = 1 + FPDF_FORMFIELD_CHECKBOX = 2 + FPDF_FORMFIELD_RADIOBUTTON = 3 + FPDF_FORMFIELD_COMBOBOX = 4 + FPDF_FORMFIELD_LISTBOX = 5 + FPDF_FORMFIELD_TEXTFIELD = 6 + FPDF_FORMFIELD_SIGNATURE = 7 + + FPDF_FORMFLAG_REQUIRED = 1 << 1 + FPDF_FORMFLAG_TEXT_PASSWORD = 1 << 13 + FPDF_FORMFLAG_BUTTON_PUSH = 1 << 16 + FPDF_FORMFLAG_TEXT_FILE_SELECT = 1 << 20 + FPDF_FORMFLAG_TEXT_COMB = 1 << 24 + FPDF_FORMFLAG_TEXT_RICH_TEXT = 1 << 25 + + TEXT_TYPE_FLAGS = FPDF_FORMFLAG_TEXT_FILE_SELECT | + FPDF_FORMFLAG_TEXT_PASSWORD | + FPDF_FORMFLAG_TEXT_RICH_TEXT + + FPDF_ANNOT_AACTION_KEY_STROKE = 12 + FPDF_ANNOT_AACTION_FORMAT = 13 + + typedef :pointer, :FPDF_LINK + typedef :pointer, :FPDF_ACTION + + attach_function :FPDFAnnot_GetLink, [:FPDF_ANNOTATION], :FPDF_LINK + attach_function :FPDFLink_GetAction, [:FPDF_LINK], :FPDF_ACTION + attach_function :FPDFAction_GetType, [:FPDF_ACTION], :ulong + attach_function :FPDFAction_GetURIPath, %i[FPDF_DOCUMENT FPDF_ACTION pointer ulong], :ulong + + PDFACTION_URI = 3 + attach_function :FPDFPage_Flatten, %i[FPDF_PAGE int], :int FLAT_NORMALDISPLAY = 0 @@ -303,6 +372,7 @@ class Pdfium attach_function :FPDFPage_SetRotation, %i[FPDF_PAGE int], :void attach_function :FPDFPage_TransFormWithClip, %i[FPDF_PAGE pointer pointer], :int attach_function :FPDFPage_TransformAnnots, %i[FPDF_PAGE double double double double double double], :void + attach_function :FPDF_GetPageBoundingBox, %i[FPDF_PAGE pointer], :int attach_function :FPDFPage_GetMediaBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int attach_function :FPDFPage_SetMediaBox, %i[FPDF_PAGE float float float float], :void attach_function :FPDFPage_GetCropBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int @@ -383,6 +453,60 @@ class Pdfium yield instance end + def self.transform_rect(bounds, box:, rotation:) + x0, y0, x1, y1 = bounds + box_x0, box_y0, box_x1, box_y1 = box + + box_width = box_x1 - box_x0 + box_height = box_y1 - box_y0 + + left = x0 - box_x0 + right = x1 - box_x0 + bottom = y0 - box_y0 + top = y1 - box_y0 + + case rotation + when 1 + [bottom, left, top - bottom, right - left, box_height, box_width] + when 2 + [box_width - right, bottom, right - left, top - bottom, box_width, box_height] + when 3 + [box_height - top, box_width - right, top - bottom, right - left, box_height, box_width] + else + [left, box_height - top, right - left, top - bottom, box_width, box_height] + end + end + + def self.read_wide_string + length = yield(FFI::Pointer::NULL, 0) + + return if length <= 2 + + buffer = FFI::MemoryPointer.new(:uint8, length) + + length = yield(buffer, length) + + return if length <= 2 + + buffer.read_bytes(length - 2) + .force_encoding('UTF-16LE') + .encode('UTF-8', invalid: :replace, undef: :replace, replace: '') + end + + def self.read_byte_string + length = yield(FFI::Pointer::NULL, 0) + + return if length <= 1 + + buffer = FFI::MemoryPointer.new(:uint8, length) + + length = yield(buffer, length) + + return if length <= 1 + + buffer.read_bytes(length - 1).force_encoding('UTF-8').scrub + end + def self.check_last_error(context_message = 'PDFium operation failed') error_code = FPDF_GetLastError() @@ -401,6 +525,7 @@ class Pdfium @document_ptr = document_ptr @pages = {} + @annot_counts = {} @closed = false @source_buffer = source_buffer @form_handle = FFI::Pointer::NULL @@ -597,6 +722,14 @@ class Pdfium Pdfium.FPDF_DocumentHasValidCrossReferenceTable(@document_ptr) == 1 end + def annot_count(page_index) + @annot_counts[page_index] ||= Pdfium.FPDFPage_GetAnnotCountRaw(@document_ptr, page_index) + end + + def reset_annot_count(page_index) + @annot_counts.delete(page_index) + end + def signature_count @signature_count ||= Pdfium.FPDF_GetSignatureCount(@document_ptr) end @@ -757,8 +890,6 @@ class Pdfium end class Page - RECT_KEYS = %i[left top right bottom].freeze - attr_reader :document, :page_index, :page_ptr def initialize(document, page_index) @@ -789,30 +920,36 @@ class Pdfium end def annotations - (0...Pdfium.FPDFPage_GetAnnotCount(page_ptr)).filter_map do |index| - annotation = Pdfium.FPDFPage_GetAnnot(page_ptr, index) + ensure_not_closed! - next if annotation.null? - - begin - rect = Pdfium::FS_RECTF.new - Pdfium.FPDFAnnot_GetRect(annotation, rect) - - [Pdfium.FPDFAnnot_GetSubtype(annotation), - *RECT_KEYS.map { |key| rect[key].round(3) }] - ensure - Pdfium.FPDFPage_CloseAnnot(annotation) + @annotations ||= + (0...Pdfium.FPDFPage_GetAnnotCount(page_ptr)).filter_map do |index| + with_annotation(index) do |handle| + Annotation.new(page: self, index:, subtype: handle.subtype, rect: handle.rect) + end end - end end def objects - (0...Pdfium.FPDFPage_CountObjects(page_ptr)).map do |index| - object = Pdfium.FPDFPage_GetObject(page_ptr, index) - bounds = Array.new(4) { FFI::MemoryPointer.new(:float) } - Pdfium.FPDFPageObj_GetBounds(object, *bounds) + ensure_not_closed! - [Pdfium.FPDFPageObj_GetType(object), *bounds.map { |b| b.read_float.round(3) }] + @objects ||= + (0...Pdfium.FPDFPage_CountObjects(page_ptr)).map do |index| + PageObject.new(page: self, index:) + end + end + + def with_annotation(index) + ensure_not_closed! + + annot_ptr = Pdfium.FPDFPage_GetAnnot(page_ptr, index) + + return if annot_ptr.null? + + begin + yield AnnotationHandle.new(self, annot_ptr) + ensure + Pdfium.FPDFPage_CloseAnnot(annot_ptr) end end @@ -826,6 +963,18 @@ class Pdfium @rotation = value end + def box + @box ||= read_bounding_box || [0, 0, 612, 792] + end + + def load_page_view + return if @page_view || form_handle.null? + + Pdfium.FORM_OnAfterLoadPage(@page_ptr, form_handle) + + @page_view = true + end + def closed? @closed end @@ -987,7 +1136,7 @@ class Pdfium @document.add_presave_hook(:cleanup) { @document.cleanup } - reset_text_memoization + reset_memoization nil end @@ -1085,7 +1234,7 @@ class Pdfium Pdfium.FPDFPage_GenerateContent(@page_ptr) if unwrapped - reset_text_memoization if unwrapped + reset_memoization if unwrapped end def find_form_object(rect_bounds = nil) @@ -1202,12 +1351,14 @@ class Pdfium end end - def reset_text_memoization + def reset_memoization remove_instance_variable(:@text) if defined?(@text) @text_nodes = nil @text_objects = nil @line_nodes = nil + @annotations = nil + @objects = nil end def remove_page_object(object_ptr) @@ -1603,23 +1754,32 @@ class Pdfium end def reload + close_page_view Pdfium.FPDF_ClosePage(@page_ptr) @page_ptr = Pdfium.FPDF_LoadPage(@document.document_ptr, @page_index) raise PdfiumError, "Failed to reload page #{page_index}" if @page_ptr.null? + @page_view = false @rotation = nil @width = nil @height = nil + @box = nil - reset_text_memoization + @document.reset_annot_count(@page_index) + + reset_memoization end def close return if closed? - Pdfium.FPDF_ClosePage(@page_ptr) unless @page_ptr.null? + unless @page_ptr.null? + close_page_view + + Pdfium.FPDF_ClosePage(@page_ptr) + end @page_ptr = FFI::Pointer::NULL @@ -1628,6 +1788,25 @@ class Pdfium private + def close_page_view + Pdfium.FORM_OnBeforeClosePage(@page_ptr, form_handle) unless form_handle.null? + + @page_view = false + end + + def read_bounding_box + rect = Pdfium::FS_RECTF.new + + return unless Pdfium.FPDF_GetPageBoundingBox(page_ptr, rect) == 1 + + x0, x1 = [rect[:left], rect[:right]].minmax + y0, y1 = [rect[:bottom], rect[:top]].minmax + + return if x1 - x0 <= 0 || y1 - y0 <= 0 + + [x0, y0, x1, y1] + end + def calculate_render_dimensions(width_param, height_param, scale_param) if scale_param render_width = (width * scale_param).round @@ -1654,6 +1833,407 @@ class Pdfium end end + class AnnotationHandle + attr_reader :page, :annot_ptr + + delegate :form_handle, to: :page + + def initialize(page, annot_ptr) + @page = page + @annot_ptr = annot_ptr + end + + def subtype + Pdfium.FPDFAnnot_GetSubtype(annot_ptr) + end + + def rect + rect = Pdfium::FS_RECTF.new + + return unless Pdfium.FPDFAnnot_GetRect(annot_ptr, rect) == 1 + + left, right = [rect[:left], rect[:right]].minmax + bottom, top = [rect[:bottom], rect[:top]].minmax + + [left, bottom, right, top] + end + + def field_type + Pdfium.FPDFAnnot_GetFormFieldType(form_handle, annot_ptr) + end + + def field_flags + Pdfium.FPDFAnnot_GetFormFieldFlags(form_handle, annot_ptr) + end + + def field_name + read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldName(form_handle, annot_ptr, buffer, length) } + end + + def alternate_name + read_wide do |buffer, length| + Pdfium.FPDFAnnot_GetFormFieldAlternateName(form_handle, annot_ptr, buffer, length) + end + end + + def field_value + read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldValue(form_handle, annot_ptr, buffer, length) } + end + + def export_value + read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldExportValue(form_handle, annot_ptr, buffer, length) } + end + + def control_count + Pdfium.FPDFAnnot_GetFormControlCount(form_handle, annot_ptr) + end + + def control_index + Pdfium.FPDFAnnot_GetFormControlIndex(form_handle, annot_ptr) + end + + def checked? + Pdfium.FPDFAnnot_IsChecked(form_handle, annot_ptr) == 1 + end + + def option_labels + count = Pdfium.FPDFAnnot_GetOptionCount(form_handle, annot_ptr) + + return [] if count < 1 + + (0...count).map do |index| + read_wide do |buffer, length| + Pdfium.FPDFAnnot_GetOptionLabel(form_handle, annot_ptr, index, buffer, length) + end.to_s + end + end + + def action_javascript(event) + read_wide do |buffer, length| + Pdfium.FPDFAnnot_GetFormAdditionalActionJavaScript(form_handle, annot_ptr, event, buffer, length) + end + end + + def link_uri + link = Pdfium.FPDFAnnot_GetLink(annot_ptr) + + return if link.null? + + action = Pdfium.FPDFLink_GetAction(link) + + return if action.null? || Pdfium.FPDFAction_GetType(action) != Pdfium::PDFACTION_URI + + document_ptr = page.document.document_ptr + + Pdfium.read_byte_string { |buffer, length| Pdfium.FPDFAction_GetURIPath(document_ptr, action, buffer, length) } + end + + def number_value(key) + value = FFI::MemoryPointer.new(:float) + + return unless Pdfium.FPDFAnnot_GetNumberValue(annot_ptr, key, value) == 1 + + value.read_float + end + + def string_value(key) + read_wide { |buffer, length| Pdfium.FPDFAnnot_GetStringValue(annot_ptr, key, buffer, length) } + end + + private + + def read_wide(&) + Pdfium.read_wide_string(&) + end + end + + class Annotation + FIELD_TYPES = { + Pdfium::FPDF_FORMFIELD_PUSHBUTTON => :pushbutton, + Pdfium::FPDF_FORMFIELD_CHECKBOX => :checkbox, + Pdfium::FPDF_FORMFIELD_RADIOBUTTON => :radio, + Pdfium::FPDF_FORMFIELD_COMBOBOX => :combobox, + Pdfium::FPDF_FORMFIELD_LISTBOX => :listbox, + Pdfium::FPDF_FORMFIELD_TEXTFIELD => :text, + Pdfium::FPDF_FORMFIELD_SIGNATURE => :signature + }.freeze + + class Link + attr_reader :url + + def initialize(url) + @url = url + end + end + + attr_reader :page, :index, :subtype, :bounds + + delegate :page_index, to: :page + + def initialize(page:, index:, subtype:, rect: nil) + @page = page + @index = index + @subtype = subtype + @bounds = rect.to_a + end + + def left + bounds[0] + end + + def bottom + bounds[1] + end + + def right + bounds[2] + end + + def top + bounds[3] + end + + def widget? + subtype == Pdfium::FPDF_ANNOT_WIDGET + end + + def link? + subtype == Pdfium::FPDF_ANNOT_LINK + end + + def rect? + bounds.any? + end + + def width + right - left + end + + def height + top - bottom + end + + def link + @link = link? ? page.with_annotation(index) { |handle| Link.new(handle.link_uri) } : nil unless defined?(@link) + + @link + end + + def field + @field = widget? ? page.with_annotation(index) { |handle| Field.new(handle) } : nil unless defined?(@field) + + @field + end + + def to_a + [index, subtype, *bounds] + end + + def ==(other) + other.is_a?(Annotation) && to_a == other.to_a + end + + alias eql? == + + def hash + [Annotation, *to_a].hash + end + end + + class Field + attr_reader :type, :flags, :name, :alternate_name, :value, :export_value, :control_index, + :control_count, :options, :checked, :max_len, :quadding, :format_js, + :keystroke_js, :dict_type, :partial_name + + def initialize(handle) + code = handle.field_type + + @detached = code.negative? + @dict_type = handle.string_value('FT') + @partial_name = handle.string_value('T') + @options = [] + + @detached ? read_detached(handle) : read_form(handle, code) + end + + def checked? + checked == true + end + + def detached? + @detached + end + + def required? + flags.to_i.anybits?(Pdfium::FPDF_FORMFLAG_REQUIRED) + end + + def comb? + flags.to_i.anybits?(Pdfium::FPDF_FORMFLAG_TEXT_COMB) && flags.to_i.nobits?(Pdfium::TEXT_TYPE_FLAGS) + end + + def button? + type.in?(%i[checkbox radio]) + end + + def own? + dict_type.present? && partial_name.present? + end + + private + + def read_form(handle, code) + @type = Annotation::FIELD_TYPES.fetch(code, :unknown) + @flags = handle.field_flags + @name = handle.field_name.to_s + @alternate_name = handle.alternate_name + @value = handle.field_value + @control_index = handle.control_index + @control_count = handle.control_count + + read_form_extras(handle) + end + + def read_form_extras(handle) + case type + when :checkbox, :radio + handle.page.load_page_view + + @checked = handle.checked? + @export_value = handle.export_value + @options = handle.option_labels if control_index.zero? + when :combobox, :listbox + @options = handle.option_labels + when :text + read_text_extras(handle) + end + end + + def read_text_extras(handle) + @max_len = handle.number_value('MaxLen') + @quadding = handle.number_value('Q') + @format_js = handle.action_javascript(Pdfium::FPDF_ANNOT_AACTION_FORMAT) + @keystroke_js = handle.action_javascript(Pdfium::FPDF_ANNOT_AACTION_KEY_STROKE) + end + + def read_detached(handle) + @flags = handle.number_value('Ff').to_i + @value = handle.string_value('V') + @name = partial_name.to_s + @alternate_name = handle.string_value('TU') + @control_index = 0 + @control_count = 1 + @type = detached_type + + if type == :text + @max_len = handle.number_value('MaxLen') + @quadding = handle.number_value('Q') + else + @checked = value.present? && value != 'Off' + end + end + + def detached_type + case dict_type + when 'Tx' then :text + when 'Sig' then :signature + when 'Btn' then flags.anybits?(Pdfium::FPDF_FORMFLAG_BUTTON_PUSH) ? :pushbutton : :checkbox + else :unknown + end + end + end + + class PageObject + attr_reader :page, :index + + def initialize(page:, index:) + @page = page + @index = index + end + + def type + @type ||= Pdfium::PAGE_OBJECT_TYPES.fetch(Pdfium.FPDFPageObj_GetType(object_ptr), :unknown) + end + + def bounds + @bounds ||= read_bounds + end + + def left + bounds[0] + end + + def bottom + bounds[1] + end + + def right + bounds[2] + end + + def top + bounds[3] + end + + def rect? + bounds.any? + end + + def width + right - left + end + + def height + top - bottom + end + + def text? + type == :text + end + + def image? + type == :image + end + + def path? + type == :path + end + + def form? + type == :form + end + + def to_a + [index, type, *bounds] + end + + def ==(other) + other.is_a?(PageObject) && to_a == other.to_a + end + + alias eql? == + + def hash + [PageObject, *to_a].hash + end + + private + + def object_ptr + page.ensure_not_closed! + + Pdfium.FPDFPage_GetObject(page.page_ptr, index) + end + + def read_bounds + buffer = Array.new(4) { FFI::MemoryPointer.new(:float) } + + return [] unless Pdfium.FPDFPageObj_GetBounds(object_ptr, *buffer) == 1 + + buffer.map(&:read_float) + end + end + def self.initialize_library config_mem = FFI::MemoryPointer.new(FPDF_LIBRARY_CONFIG.size) diff --git a/lib/templates/build_pdfium_annotations.rb b/lib/templates/build_pdfium_annotations.rb new file mode 100644 index 00000000..bec83ea9 --- /dev/null +++ b/lib/templates/build_pdfium_annotations.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Templates + module BuildPdfiumAnnotations + URI_PREFIXES = %w[https:// http://].freeze + LINKS_LIMIT = 250 + + module_function + + def call(doc) + annotations = [] + + doc.page_count.times do |page_index| + break if annotations.size >= LINKS_LIMIT + next if doc.annot_count(page_index).zero? + + page = doc.get_page(page_index) + geometry = { box: page.box, rotation: page.rotation } + + page.annotations.each do |annotation| + next unless annotation.link? && annotation.rect? + + url = annotation.link.url + + next if url.blank? || URI_PREFIXES.none? { |prefix| url.start_with?(prefix) } + + annotations << build_external_link_hash(url, annotation, geometry).merge('page' => page_index) + end + end + + annotations + end + + def build_external_link_hash(url, area, geometry) + x, y, w, h, page_width, page_height = Pdfium.transform_rect(area.bounds, **geometry) + + { + 'type' => 'external_link', + 'value' => url, + 'x' => x / page_width, + 'y' => y / page_height, + 'w' => w / page_width, + 'h' => h / page_height + } + end + end +end diff --git a/lib/templates/find_pdfium_acro_fields.rb b/lib/templates/find_pdfium_acro_fields.rb new file mode 100644 index 00000000..32d48cc4 --- /dev/null +++ b/lib/templates/find_pdfium_acro_fields.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +module Templates + module FindPdfiumAcroFields + DATE_JS_PREFIX = 'AFDate_' + SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze + + module_function + + def call(attachment, doc) + pages = {} + widgets = [] + + doc.page_count.times do |page_index| + next if doc.annot_count(page_index).zero? + + page = doc.get_page(page_index) + + pages[page_index] = { box: page.box, rotation: page.rotation } + + page.annotations.each do |annotation| + next unless annotation.widget? && annotation.rect? + + field = annotation.field + + next if field.nil? || field.type.in?(SKIP_FIELD_TYPES) + + widgets << annotation + end + end + + group_widgets(widgets).filter_map { |field_widgets| build_field(field_widgets, pages, attachment) } + end + + def group_widgets(widgets) + widgets.group_by do |annotation| + field = annotation.field + + if (field.control_count > 1 && field.own?) || field.detached? || (field.name.blank? && field.control_count <= 1) + [:widget, annotation.page_index, annotation.index] + else + [:field, field.type, field.name] + end + end.values + end + + def build_field(widgets, pages, attachment) + widgets = widgets.sort_by.with_index { |annotation, index| [annotation.field.control_index, index] } + + areas = widgets.filter_map { |annotation| build_area(annotation, pages[annotation.page_index], attachment) } + + return if areas.blank? + + field_properties = build_field_properties(widgets) + + return if field_properties.blank? + return if field_properties[:default_value].present? + + if field_properties[:type] == 'radio' + 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: widgets.first.field.required?, + preferences: {}, + areas:, + **field_properties + } + end + + def build_area(annotation, page, attachment) + x, y, w, h, page_width, page_height = Pdfium.transform_rect(annotation.bounds, **page) + + attrs = { + page: annotation.page_index, + x: x / page_width, + y: y / page_height, + w: w / page_width, + h: h / page_height, + attachment_uuid: attachment.uuid + } + + return if attrs[:w].zero? || attrs[:h].zero? + + field = annotation.field + + attrs[:cell_w] = attrs[:w] / field.max_len if field.comb? && field.max_len.to_f.positive? + + attrs + end + + def build_field_properties(widgets) + field = widgets.first.field + + field_name = field.name if field.name.match?(FindAcroFields::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) + + case field.type + when :checkbox, :radio + build_button_properties(attrs, widgets) + when :combobox + build_select_properties(attrs, field) + when :text + build_text_properties(attrs, field) + when :signature + { + **attrs, + type: field.name.to_s.downcase.include?('initials') ? 'initials' : 'signature' + } + else + {} + end.compact + end + + def build_button_properties(attrs, widgets) + field = widgets.first.field + options = widgets.find { |w| w.field.options.present? }&.field&.options.to_a + checked = widgets.find { |w| w.field.checked? } + export_values = widgets.filter_map { |w| w.field.export_value.presence }.uniq + + if field.type == :radio && options.present? + { + **attrs, + type: 'radio', + options: build_options(options, 'radio'), + default_value: checked && options[checked.field.control_index] + } + elsif field.control_count > 1 && export_values.size > 1 + { + **attrs, + type: 'radio', + options: build_options(export_values.map(&:to_sym), 'radio'), + default_value: checked&.field&.export_value + } + else + { + **attrs, + type: 'checkbox', + default_value: checked.present? + } + end + end + + def build_select_properties(attrs, field) + return {} if field.options.blank? + + { + **attrs, + type: 'select', + options: build_options(field.options, 'select'), + default_value: field.value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : field.value + } + end + + def build_text_properties(attrs, field) + preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') } + + attrs = { **attrs, preferences: } + + if field.comb? + { **attrs, type: 'cells', default_value: field.value } + elsif date?(field) + format = [field.format_js, field.keystroke_js].compact + .filter_map { |js| js[FindAcroFields::DATE_FORMAT_REGEXP] } + .first + + preferences[:format] = format.upcase if format + + { **attrs, type: 'date', default_value: field.value } + else + { **attrs, type: 'text', default_value: field.value } + end + end + + def date?(field) + field.format_js.to_s.include?(DATE_JS_PREFIX) || field.keystroke_js.to_s.include?(DATE_JS_PREFIX) + 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?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) + + { + uuid: SecureRandom.uuid, + value: is_option_number || is_skip_single_value ? '' : option + } + end + end + end +end From df63acf0c8ef24bc84618760d43dfa5d2aa0bef5 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 15 Aug 2026 19:08:52 +0300 Subject: [PATCH 07/18] optimize decrypt --- lib/pdf_utils.rb | 12 +++--------- lib/pdfium.rb | 5 ++++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lib/pdf_utils.rb b/lib/pdf_utils.rb index 083ec47d..938f4e4d 100644 --- a/lib/pdf_utils.rb +++ b/lib/pdf_utils.rb @@ -15,17 +15,11 @@ module PdfUtils end def decrypt(data, password) - encrypted_doc = HexaPDF::Document.new(io: StringIO.new(data), decryption_opts: { password: }) - - decrypted_doc = HexaPDF::Document.new - - encrypted_doc.pages.each do |page| - decrypted_doc.pages << decrypted_doc.import(page) - end - decrypted_io = StringIO.new - decrypted_doc.write(decrypted_io, validate: false) + Pdfium::Document.open_bytes(data, password) do |doc| + doc.save(decrypted_io, flags: Pdfium::FPDF_REMOVE_SECURITY) + end decrypted_io.tap(&:rewind).read end diff --git a/lib/pdfium.rb b/lib/pdfium.rb index d7fad6ef..736e01b8 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -444,6 +444,7 @@ class Pdfium }.freeze class PdfiumError < StandardError; end + class PasswordError < PdfiumError; end def self.error_message(code) PDFIUM_ERRORS[code] || "Unknown error code: #{code}" @@ -512,7 +513,9 @@ class Pdfium return if error_code == FPDF_ERR_SUCCESS - raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})" + error_class = error_code == FPDF_ERR_PASSWORD ? PasswordError : PdfiumError + + raise error_class, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})" end # rubocop:disable Metrics From 27d075ed63e2c7a77af91384f00db64010a6b1e0 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 13 Aug 2026 09:52:58 +0300 Subject: [PATCH 08/18] optimize merge pdf --- lib/pdf_utils.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/pdf_utils.rb b/lib/pdf_utils.rb index 938f4e4d..71595479 100644 --- a/lib/pdf_utils.rb +++ b/lib/pdf_utils.rb @@ -24,19 +24,19 @@ module PdfUtils decrypted_io.tap(&:rewind).read end - def merge(files) - merged_pdf = HexaPDF::Document.new + def merge(io_files) + merged_content = StringIO.new - files.each do |file| - pdf = HexaPDF::Document.new(io: file) - pdf.pages.each { |page| merged_pdf.pages << merged_pdf.import(page) } + Pdfium.with_instance do + Pdfium::Document.create do |merged_pdf| + io_files.each do |io| + Pdfium::Document.open_io(io) { |pdf| merged_pdf.import_pages(pdf) } + end + + merged_pdf.save(merged_content) + end end - merged_content = StringIO.new - merged_pdf.validate(auto_correct: true) - merged_pdf.write(merged_content, validate: false) - merged_content.rewind - - merged_content + merged_content.tap(&:rewind) end end From ee1d45d461c39a76e56c88fd1e972543ceef4ffd Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 13 Aug 2026 11:02:02 +0300 Subject: [PATCH 09/18] update dep --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e3346423..94faabc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ RUN apk --no-cache add wget unzip && \ wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ - wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \ + wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" && \ case "$(uname -m)" in \ - x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \ - aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \ + x86_64) echo "077239c1ef9f0a9d592b4832daa6c733fa20a518dc3e6c1c7700994230b6f22c pdfium-linux.zip" ;; \ + aarch64) echo "a5320e0d569e60b20907e57ae25d69720e25adde74ceb1123c279cf1592f645c pdfium-linux.zip" ;; \ esac | sha256sum -c - && \ mkdir -p /pdfium-linux && \ unzip -q pdfium-linux.zip -d /pdfium-linux From c8d3af9841f3ab9f3a6a16cf91a24b41174ffb0e Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 13 Aug 2026 15:55:26 +0300 Subject: [PATCH 10/18] import acro form --- Dockerfile | 4 ++-- lib/pdfium.rb | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 94faabc7..8b0bd9d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,8 @@ RUN apk --no-cache add wget unzip && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" && \ case "$(uname -m)" in \ - x86_64) echo "077239c1ef9f0a9d592b4832daa6c733fa20a518dc3e6c1c7700994230b6f22c pdfium-linux.zip" ;; \ - aarch64) echo "a5320e0d569e60b20907e57ae25d69720e25adde74ceb1123c279cf1592f645c pdfium-linux.zip" ;; \ + x86_64) echo "97c584564dfd1d772e98bf6695efc4f8da3ff5cf969460ebc92b3642d136e279 pdfium-linux.zip" ;; \ + aarch64) echo "e004ba036b4dd36649f0116e14030b9216f762b3259d33035ee883659507c917 pdfium-linux.zip" ;; \ esac | sha256sum -c - && \ mkdir -p /pdfium-linux && \ unzip -q pdfium-linux.zip -d /pdfium-linux diff --git a/lib/pdfium.rb b/lib/pdfium.rb index 736e01b8..e3c520ff 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -425,6 +425,12 @@ class Pdfium define_singleton_method(:FPDF_RemoveOrphanObjects) { |*| -1 } # rubocop:disable Naming/MethodName end + begin + attach_function :FPDF_ImportAcroForm, %i[FPDF_DOCUMENT FPDF_DOCUMENT], :int + rescue FFI::NotFoundError + define_singleton_method(:FPDF_ImportAcroForm) { |*| -1 } # rubocop:disable Naming/MethodName + end + FPDF_ERR_SUCCESS = 0 FPDF_ERR_UNKNOWN = 1 FPDF_ERR_FILE = 2 @@ -560,6 +566,8 @@ class Pdfium raise PdfiumError, 'Failed to import pages' if result.zero? + Pdfium.FPDF_ImportAcroForm(@document_ptr, src_doc.document_ptr) + @page_count = nil result From 22c146d5640b034580956554f1a02da04e45b74d Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 14 Aug 2026 16:44:42 +0300 Subject: [PATCH 11/18] adjust pdfium --- Dockerfile | 4 +- lib/pdfium.rb | 82 +++++++++++++++-------- lib/templates/build_pdfium_annotations.rb | 6 ++ lib/templates/find_acro_fields.rb | 12 ++-- lib/templates/find_pdfium_acro_fields.rb | 37 +++++++--- 5 files changed, 97 insertions(+), 44 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8b0bd9d5..93c2836e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,8 @@ RUN apk --no-cache add wget unzip && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" && \ case "$(uname -m)" in \ - x86_64) echo "97c584564dfd1d772e98bf6695efc4f8da3ff5cf969460ebc92b3642d136e279 pdfium-linux.zip" ;; \ - aarch64) echo "e004ba036b4dd36649f0116e14030b9216f762b3259d33035ee883659507c917 pdfium-linux.zip" ;; \ + x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \ + aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; \ esac | sha256sum -c - && \ mkdir -p /pdfium-linux && \ unzip -q pdfium-linux.zip -d /pdfium-linux diff --git a/lib/pdfium.rb b/lib/pdfium.rb index e3c520ff..7e993464 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -108,6 +108,8 @@ class Pdfium attach_function :FPDF_GetLastError, [], :ulong attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int + attach_function :FPDF_GetSecurityHandlerRevision, [:FPDF_DOCUMENT], :int + attach_function :FPDF_GetFormType, [:FPDF_DOCUMENT], :int attach_function :FPDFBookmark_GetFirstChild, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK attach_function :FPDFBookmark_GetNextSibling, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK @@ -279,12 +281,7 @@ class Pdfium attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int - begin - attach_function :FPDFPage_GetAnnotCountRaw, %i[FPDF_DOCUMENT int], :int - rescue FFI::NotFoundError - define_singleton_method(:FPDFPage_GetAnnotCountRaw) { |*| -1 } # rubocop:disable Naming/MethodName - end - + attach_function :FPDFPage_GetAnnotCountRaw, %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 @@ -296,18 +293,31 @@ class Pdfium attach_function :FPDFAnnot_GetFormFieldName, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong attach_function :FPDFAnnot_GetFormFieldAlternateName, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong attach_function :FPDFAnnot_GetFormFieldValue, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormFieldValueRaw, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong attach_function :FPDFAnnot_GetFormFieldExportValue, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong + attach_function :FPDFAnnot_GetFormFieldExportValueRaw, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong attach_function :FPDFAnnot_GetFormControlCount, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int attach_function :FPDFAnnot_GetFormControlIndex, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int attach_function :FPDFAnnot_GetOptionCount, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int attach_function :FPDFAnnot_GetOptionLabel, %i[FPDF_FORMHANDLE FPDF_ANNOTATION int pointer ulong], :ulong attach_function :FPDFAnnot_IsChecked, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_IsCheckedRaw, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int + attach_function :FPDFPage_FixFormFields, %i[FPDF_FORMHANDLE FPDF_PAGE], :void attach_function :FPDFAnnot_GetFormAdditionalActionJavaScript, %i[FPDF_FORMHANDLE FPDF_ANNOTATION int pointer ulong], :ulong + attach_function :FPDFAnnot_GetFlags, [:FPDF_ANNOTATION], :int + attach_function :FPDFAnnot_SetFlags, %i[FPDF_ANNOTATION int], :int + attach_function :FPDFAnnot_GetAP, %i[FPDF_ANNOTATION int pointer ulong], :ulong FPDF_ANNOT_LINK = 2 FPDF_ANNOT_WIDGET = 20 + FPDF_ANNOT_FLAG_HIDDEN = 2 + + FPDF_ANNOT_APPEARANCEMODE_NORMAL = 0 + + FORMTYPE_NONE = 0 + FPDF_FORMFIELD_UNKNOWN = 0 FPDF_FORMFIELD_PUSHBUTTON = 1 FPDF_FORMFIELD_CHECKBOX = 2 @@ -413,23 +423,9 @@ class Pdfium attach_function :FPDF_CreateNewDocument, [], :FPDF_DOCUMENT - begin - attach_function :FPDF_ImportPages, %i[FPDF_DOCUMENT FPDF_DOCUMENT string int], :int - rescue FFI::NotFoundError - define_singleton_method(:FPDF_ImportPages) { |*| raise PdfiumError, 'FPDF_ImportPages is not available' } # rubocop:disable Naming/MethodName - end - - begin - attach_function :FPDF_RemoveOrphanObjects, [:FPDF_DOCUMENT], :int - rescue FFI::NotFoundError - define_singleton_method(:FPDF_RemoveOrphanObjects) { |*| -1 } # rubocop:disable Naming/MethodName - end - - begin - attach_function :FPDF_ImportAcroForm, %i[FPDF_DOCUMENT FPDF_DOCUMENT], :int - rescue FFI::NotFoundError - define_singleton_method(:FPDF_ImportAcroForm) { |*| -1 } # rubocop:disable Naming/MethodName - end + attach_function :FPDF_ImportPages, %i[FPDF_DOCUMENT FPDF_DOCUMENT string int], :int + attach_function :FPDF_RemoveOrphanObjects, [:FPDF_DOCUMENT], :int + attach_function :FPDF_ImportAcroForm, %i[FPDF_DOCUMENT FPDF_DOCUMENT], :int FPDF_ERR_SUCCESS = 0 FPDF_ERR_UNKNOWN = 1 @@ -559,6 +555,14 @@ class Pdfium @page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr) end + def encrypted? + Pdfium.FPDF_GetSecurityHandlerRevision(@document_ptr) >= 0 + end + + def form? + Pdfium.FPDF_GetFormType(@document_ptr) != Pdfium::FORMTYPE_NONE + end + def import_pages(src_doc, pages: nil, index: nil) ensure_not_closed! @@ -978,6 +982,14 @@ class Pdfium @box ||= read_bounding_box || [0, 0, 612, 792] end + def fix_form_fields + return if @form_fields_fixed || form_handle.null? + + @form_fields_fixed = true + + Pdfium.FPDFPage_FixFormFields(form_handle, @page_ptr) + end + def load_page_view return if @page_view || form_handle.null? @@ -1858,6 +1870,12 @@ class Pdfium Pdfium.FPDFAnnot_GetSubtype(annot_ptr) end + def hide! + flags = Pdfium.FPDFAnnot_GetFlags(annot_ptr) + + Pdfium.FPDFAnnot_SetFlags(annot_ptr, flags | Pdfium::FPDF_ANNOT_FLAG_HIDDEN) == 1 + end + def rect rect = Pdfium::FS_RECTF.new @@ -1888,11 +1906,15 @@ class Pdfium end def field_value - read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldValue(form_handle, annot_ptr, buffer, length) } + read_wide do |buffer, length| + Pdfium.FPDFAnnot_GetFormFieldValueRaw(form_handle, annot_ptr, buffer, length) + end end def export_value - read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldExportValue(form_handle, annot_ptr, buffer, length) } + read_wide do |buffer, length| + Pdfium.FPDFAnnot_GetFormFieldExportValueRaw(form_handle, annot_ptr, buffer, length) + end end def control_count @@ -1904,7 +1926,7 @@ class Pdfium end def checked? - Pdfium.FPDFAnnot_IsChecked(form_handle, annot_ptr) == 1 + Pdfium.FPDFAnnot_IsCheckedRaw(form_handle, annot_ptr) == 1 end def option_labels @@ -1951,6 +1973,12 @@ class Pdfium read_wide { |buffer, length| Pdfium.FPDFAnnot_GetStringValue(annot_ptr, key, buffer, length) } end + def appearance + read_wide do |buffer, length| + Pdfium.FPDFAnnot_GetAP(annot_ptr, Pdfium::FPDF_ANNOT_APPEARANCEMODE_NORMAL, buffer, length) + end + end + private def read_wide(&) @@ -2108,7 +2136,7 @@ class Pdfium def read_form_extras(handle) case type when :checkbox, :radio - handle.page.load_page_view + handle.page.fix_form_fields @checked = handle.checked? @export_value = handle.export_value diff --git a/lib/templates/build_pdfium_annotations.rb b/lib/templates/build_pdfium_annotations.rb index bec83ea9..7aad6492 100644 --- a/lib/templates/build_pdfium_annotations.rb +++ b/lib/templates/build_pdfium_annotations.rb @@ -29,6 +29,12 @@ module Templates end annotations + rescue StandardError => e + Rollbar.error(e) if defined?(Rollbar) + + raise if Rails.env.development? + + [] end def build_external_link_hash(url, area, geometry) diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb index 16d52add..6da0bf72 100644 --- a/lib/templates/find_acro_fields.rb +++ b/lib/templates/find_acro_fields.rb @@ -173,20 +173,20 @@ module Templates **attrs, type: 'select', options: build_options(field[:Opt], 'select'), - default_value: field.field_value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : field.field_value + 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 + 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 + default_value: field.field_value.presence } elsif field.field_type == :Tx if field[:AA] && ((field[:AA][:F] && field[:AA][:F][:JS].include?('AFDate_')) || @@ -199,13 +199,13 @@ module Templates { **attrs, type: 'date', - default_value: field.field_value + default_value: field.field_value.presence } else { **attrs, type: 'text', - default_value: field.field_value + default_value: field.field_value.presence } end elsif field.field_type == :Sig @@ -234,7 +234,7 @@ module Templates { uuid: SecureRandom.uuid, - value: is_option_number || is_skip_single_value ? '' : option + value: is_option_number || is_skip_single_value ? '' : option.presence } end end diff --git a/lib/templates/find_pdfium_acro_fields.rb b/lib/templates/find_pdfium_acro_fields.rb index 32d48cc4..d9133aaa 100644 --- a/lib/templates/find_pdfium_acro_fields.rb +++ b/lib/templates/find_pdfium_acro_fields.rb @@ -4,10 +4,13 @@ module Templates module FindPdfiumAcroFields DATE_JS_PREFIX = 'AFDate_' SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze + TEXT_OPERATOR_REGEXP = /\bT[jJ]\b/ module_function - def call(attachment, doc) + def call(attachment, doc, data) + return [] if !doc.form? && data.exclude?('/Form') + pages = {} widgets = [] @@ -30,6 +33,12 @@ module Templates end group_widgets(widgets).filter_map { |field_widgets| build_field(field_widgets, pages, attachment) } + rescue StandardError => e + Rollbar.error(e) if defined?(Rollbar) + + raise if Rails.env.development? + + [] end def group_widgets(widgets) @@ -110,7 +119,7 @@ module Templates when :checkbox, :radio build_button_properties(attrs, widgets) when :combobox - build_select_properties(attrs, field) + build_select_properties(attrs, widgets.first) when :text build_text_properties(attrs, field) when :signature @@ -141,7 +150,7 @@ module Templates **attrs, type: 'radio', options: build_options(export_values.map(&:to_sym), 'radio'), - default_value: checked&.field&.export_value + default_value: checked&.field&.export_value.presence } else { @@ -152,24 +161,34 @@ module Templates end end - def build_select_properties(attrs, field) + def build_select_properties(attrs, annotation) + field = annotation.field + return {} if field.options.blank? + value = field.value.presence if renders_text?(annotation) + { **attrs, type: 'select', options: build_options(field.options, 'select'), - default_value: field.value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : field.value + default_value: value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : value } end + def renders_text?(annotation) + appearance = annotation.page.with_annotation(annotation.index, &:appearance) + + appearance.to_s.match?(TEXT_OPERATOR_REGEXP) + end + def build_text_properties(attrs, field) preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') } attrs = { **attrs, preferences: } if field.comb? - { **attrs, type: 'cells', default_value: field.value } + { **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] } @@ -177,9 +196,9 @@ module Templates preferences[:format] = format.upcase if format - { **attrs, type: 'date', default_value: field.value } + { **attrs, type: 'date', default_value: field.value.presence } else - { **attrs, type: 'text', default_value: field.value } + { **attrs, type: 'text', default_value: field.value.presence } end end @@ -203,7 +222,7 @@ module Templates { uuid: SecureRandom.uuid, - value: is_option_number || is_skip_single_value ? '' : option + value: is_option_number || is_skip_single_value ? '' : option.presence } end end From 5308e3e15be784a9dc65abfab27584cb8a2874c2 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 15 Aug 2026 19:07:43 +0300 Subject: [PATCH 12/18] process attachment v2 --- lib/templates/create_attachments.rb | 69 +++++++++++++++++++++++------ lib/templates/process_document.rb | 59 ++++++++++++++++++------ 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/lib/templates/create_attachments.rb b/lib/templates/create_attachments.rb index 15eee218..8b3a555f 100644 --- a/lib/templates/create_attachments.rb +++ b/lib/templates/create_attachments.rb @@ -39,6 +39,8 @@ 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 @@ -48,6 +50,55 @@ module Templates 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 + document = create_document(template, file, document_data, metadata) + + return Templates::ProcessDocument.call(document, document_data, extract_fields:) + end + + doc = Pdfium::Document.open_bytes(document_data, params[:password]) + + document_data = decrypt_document(doc) if doc.encrypted? + + annotations = + document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildPdfiumAnnotations.call(doc) : [] + + document = create_document(template, file, document_data, metadata, annotations) + + Templates::ProcessDocument.call(document, document_data, extract_fields:, doc:) + rescue Pdfium::PasswordError + raise PdfEncrypted + ensure + 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 + + doc.save(io, flags: Pdfium::FPDF_REMOVE_SECURITY) + + io.tap(&:rewind).read + end + + def create_document(template, file, document_data, metadata, annotations = nil) sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data)) blob = ActiveStorage::Blob.create_and_upload!( @@ -62,19 +113,7 @@ module Templates content_type: file.content_type ) - document = template.documents.create!(blob:) - - Templates::ProcessDocument.call(document, document_data, extract_fields:) - 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 HexaPDF::EncryptionError - raise PdfEncrypted + template.documents.create!(blob:) end def extract_zip_files(files) @@ -124,5 +163,9 @@ module Templates raise InvalidFileType, "#{file.content_type}/#{dynamic}" end + + def v2? + true + end end end diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index 4e69a4c1..74fdf56f 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -19,15 +19,19 @@ module Templates module_function - def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) + 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 - pdf = HexaPDF::Document.new(io: StringIO.new(data)) + 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) + fields = Templates::FindAcroFields.call(pdf, attachment, data) + end end - generate_pdf_preview_images(attachment, data, pdf, max_pages:) + generate_pdf_preview_images(attachment, data, pdf, max_pages:, doc:) attachment.metadata['pdf']['fields'] = fields if fields elsif attachment.image? @@ -76,13 +80,17 @@ module Templates ) end - def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) + def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil) ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all - pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) - number_of_pages = pdf.pages.size + 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) + data = maybe_flatten_form(data, pdf) + end attachment.metadata['pdf'] ||= {} attachment.metadata['pdf']['number_of_pages'] = number_of_pages @@ -93,17 +101,21 @@ 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) + generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min, doc:) end - def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY) - doc = Pdfium::Document.open_bytes(data) + def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil) + flatten_pages = doc&.form? + pdfium_doc = doc || Pdfium::Document.open_bytes(data) pool = Concurrent::FixedThreadPool.new(concurrency) promises = range.map do |page_number| - doc_page = doc.get_page(page_number) + doc_page = pdfium_doc.get_page(page_number) + + hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) if doc + doc_page.flatten if flatten_pages bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH) @@ -126,10 +138,31 @@ module Templates end end ensure - doc&.close + pdfium_doc&.close if doc.nil? pool&.kill end + def hide_placeholder_widgets(page, hide_empty: true) + page.annotations.each do |annotation| + next unless annotation.widget? + + page.with_annotation(annotation.index) do |handle| + next if handle.field_type != Pdfium::FPDF_FORMFIELD_COMBOBOX + + value = handle.field_value.to_s + + if value.blank? + next unless hide_empty + elsif handle.option_labels.blank? || + !value.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) + next + end + + handle.hide! + end + end + end + def build_and_upload_blob(image, page_number, format = FORMAT) image = image.copy(interpretation: :srgb) From 3d93bbe5aa12c783b2a12267e2e0d558e575cb62 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 15 Aug 2026 21:08:08 +0300 Subject: [PATCH 13/18] improve flatten --- lib/pdfium.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/pdfium.rb b/lib/pdfium.rb index 7e993464..8d3802b1 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -1763,6 +1763,20 @@ class Pdfium def flatten(flag = Pdfium::FLAT_NORMALDISPLAY) ensure_not_closed! + load_page_view + + l_ptr, b_ptr, r_ptr, t_ptr = Array.new(4) { FFI::MemoryPointer.new(:float) } + + left, bottom, right, top = box.map(&:to_f) + + if Pdfium.FPDFPage_GetMediaBox(page_ptr, l_ptr, b_ptr, r_ptr, t_ptr).zero? + Pdfium.FPDFPage_SetMediaBox(page_ptr, left, bottom, right, top) + end + + if Pdfium.FPDFPage_GetCropBox(page_ptr, l_ptr, b_ptr, r_ptr, t_ptr).zero? + Pdfium.FPDFPage_SetCropBox(page_ptr, left, bottom, right, top) + end + result = Pdfium.FPDFPage_Flatten(page_ptr, flag) if result == Pdfium::FLATTEN_FAIL From cbbc75b485a6f2403844d67b0843eff46eafc6f9 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 15 Aug 2026 21:51:05 +0300 Subject: [PATCH 14/18] fix CI --- .github/workflows/ci.yml | 42 ++++++++++++++------------------ spec/system/signing_form_spec.rb | 7 +++--- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae923e59..11987880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,47 +112,38 @@ jobs: name: RSpec runs-on: ubuntu-latest timeout-minutes: 10 + container: + image: ruby:4.0.5-alpine services: postgres: - image: postgres:14 + image: postgres:18 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: docuseal_test - ports: ["5432:5432"] options: >- - --health-cmd pg_isready + --health-cmd "pg_isready -U postgres -d docuseal_test" --health-interval 10s --health-timeout 5s --health-retries 5 steps: + - name: Install packages + run: apk add --no-cache build-base git libpq-dev yaml-dev nodejs yarn tar zstd gzip wget unzip vips leptonica chromium - uses: actions/checkout@v4 - - name: Install Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 4.0.5 - - name: Set up Node - uses: actions/setup-node@v1 - with: - node-version: 20.19.0 - - name: Install Chrome - uses: browser-actions/setup-chrome@latest - with: - chrome-version: 125 - name: Cache node_modules uses: actions/cache@v4 with: path: node_modules - key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} + key: ${{ runner.os }}-musl-node-${{ hashFiles('**/yarn.lock') }} - name: Cache gems uses: actions/cache@v4 with: path: vendor/bundle - key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} + key: ${{ runner.os }}-musl-gem-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | - ${{ runner.os }}-gem- + ${{ runner.os }}-musl-gem- - name: Install dependencies env: RAILS_ENV: test @@ -161,17 +152,20 @@ jobs: bundle config path vendor/bundle bundle install --jobs 4 --retry 4 yarn install - sudo apt-get update - sudo apt-get install -y libvips liblept5 - wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" - sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so - rm -f pdfium-linux.tgz + wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" + case "$(uname -m)" in + x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; + aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; + esac | sha256sum -c - + unzip -q pdfium-linux.zip -d /tmp/pdfium-linux + cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so + rm -rf pdfium-linux.zip /tmp/pdfium-linux - name: Run env: RAILS_ENV: test NODE_ENV: test COVERAGE: true - DATABASE_URL: postgres://postgres:postgres@localhost:5432/docuseal_test + DATABASE_URL: postgres://postgres:postgres@postgres:5432/docuseal_test run: | bundle exec rake db:create bundle exec rake db:migrate diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb index 0002dbc4..d4c2dc43 100644 --- a/spec/system/signing_form_spec.rb +++ b/spec/system/signing_form_spec.rb @@ -1153,9 +1153,10 @@ RSpec.describe 'Signing Form' do click_on 'next' draw_canvas - expect do - click_on 'Sign and Complete' - end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1) + click_on 'Sign and Complete' + + expect(page).to have_content('Document has been signed!') + expect(ProcessSubmitterCompletionJob.jobs.size).to eq(1) end end From eec08266c96befc92b1b6c45fdaac6387c10f7e4 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 16 Aug 2026 09:45:04 +0300 Subject: [PATCH 15/18] fix page preview open --- lib/pdfium.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pdfium.rb b/lib/pdfium.rb index 8d3802b1..470c2ccf 100644 --- a/lib/pdfium.rb +++ b/lib/pdfium.rb @@ -640,7 +640,15 @@ class Pdfium end end - def self.open_io(io, password = nil) + def self.open_io(io, password = nil, &) + path = io.path if io.respond_to?(:path) + + if path + io.flush + + return open_file(path, password, &) + end + io.binmode get_block = FFI::Function.new(:int, %i[pointer ulong pointer ulong]) do |_param, position, out, size| From 2bf3d42ed61d4e28d36f84597c2de0cf5b4eda76 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Thu, 13 Aug 2026 22:07:44 +0300 Subject: [PATCH 16/18] add translations --- config/locales/i18n.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 830a4731..876ca15b 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -228,6 +228,7 @@ en: &en profile_details: Profile Details sign_up_with_google: Sign up with Google sign_up_with_microsoft: Sign up with Microsoft + redirecting_to_provider: Redirecting to %{provider}... by_creating_an_account_you_agree_to_our_html: 'By creating an account, you agree to our Privacy Policy and Terms of Service.' enter_email_to_continue: Enter email to continue account: Account @@ -1341,6 +1342,7 @@ es: &es profile_details: Detalles del perfil sign_up_with_google: Registrarse con Google sign_up_with_microsoft: Registrarse con Microsoft + redirecting_to_provider: Redirigiendo a %{provider}... by_creating_an_account_you_agree_to_our_html: 'Al crear una cuenta, aceptas nuestra Política de Privacidad y Términos de Servicio.' enter_email_to_continue: Ingresa tu correo electrónico para continuar account: Cuenta @@ -2465,6 +2467,7 @@ it: &it profile_details: Dettagli del profilo sign_up_with_google: Registrati con Google sign_up_with_microsoft: Registrati con Microsoft + redirecting_to_provider: Reindirizzamento a %{provider}... by_creating_an_account_you_agree_to_our_html: 'Creando un account, accetti la nostra Privacy Policy e i nostri Termini di servizio.' enter_email_to_continue: "Inserisci l'email per continuare" account: Account @@ -3575,6 +3578,7 @@ fr: &fr profile_details: Détails du profil sign_up_with_google: S’inscrire avec Google sign_up_with_microsoft: S’inscrire avec Microsoft + redirecting_to_provider: Redirection vers %{provider}... by_creating_an_account_you_agree_to_our_html: En créant un compte, vous acceptez notre Politique de confidentialité et nos Conditions d’utilisation. enter_email_to_continue: Entrez votre e‑mail pour continuer account: Compte @@ -4695,6 +4699,7 @@ pt: &pt profile_details: Detalhes do perfil sign_up_with_google: Inscrever-se com Google sign_up_with_microsoft: Inscrever-se com Microsoft + redirecting_to_provider: Redirecionando para %{provider}... by_creating_an_account_you_agree_to_our_html: 'Ao criar uma conta, você concorda com nossa Política de Privacidade e Termos de Serviço.' enter_email_to_continue: Insira o e-mail para continuar account: Conta @@ -5818,6 +5823,7 @@ de: &de profile_details: Profildetails sign_up_with_google: Mit Google registrieren sign_up_with_microsoft: Mit Microsoft registrieren + redirecting_to_provider: Weiterleitung zu %{provider}... by_creating_an_account_you_agree_to_our_html: 'Durch die Erstellung eines Kontos stimmen Sie unseren Datenschutzrichtlinien und Nutzungsbedingungen zu.' enter_email_to_continue: E-Mail eingeben, um fortzufahren account: Konto @@ -6808,6 +6814,7 @@ pl: profile_details: Szczegóły profilu sign_up_with_google: Zarejestruj się przez Google sign_up_with_microsoft: Zarejestruj się przez Microsoft + redirecting_to_provider: Przekierowywanie do %{provider}... by_creating_an_account_you_agree_to_our_html: 'Tworząc konto, akceptujesz naszą Politykę Prywatności i Regulamin.' enter_email_to_continue: Wprowadź e-mail, aby kontynuować the_code_has_been_sent_to_your_email: Kod został wysłany na Twój e-mail. @@ -6947,6 +6954,7 @@ uk: profile_details: Дані профілю sign_up_with_google: Зареєструватися через Google sign_up_with_microsoft: Зареєструватися через Microsoft + redirecting_to_provider: Перенаправлення до %{provider}... by_creating_an_account_you_agree_to_our_html: 'Створюючи акаунт, ви погоджуєтесь з нашою Політикою конфіденційності і Умовами надання послуг.' enter_email_to_continue: Введіть електронну пошту, щоб продовжити the_code_has_been_sent_to_your_email: Код було надіслано на вашу електронну пошту. @@ -7086,6 +7094,7 @@ cs: profile_details: Detaily profilu sign_up_with_google: Zaregistrovat se pomocí Googlu sign_up_with_microsoft: Zaregistrovat se pomocí Microsoftu + redirecting_to_provider: Přesměrování na %{provider}... by_creating_an_account_you_agree_to_our_html: 'Vytvořením účtu souhlasíte s našimi Zásadami ochrany osobních údajů a Podmínkami služby.' enter_email_to_continue: Zadejte e-mail pro pokračování the_code_has_been_sent_to_your_email: Kód byl odeslán na váš e-mail. @@ -7211,6 +7220,7 @@ he: profile_details: פרטי הפרופיל sign_up_with_google: הירשם עם גוגל sign_up_with_microsoft: הירשם עם מיקרוסופט + redirecting_to_provider: מפנה אל %{provider}... by_creating_an_account_you_agree_to_our_html: 'על ידי יצירת חשבון, אתה מסכים למדיניות הפרטיות ולתנאי השירות שלנו.' enter_email_to_continue: הכנס דוא"ל כדי להמשיך the_code_has_been_sent_to_your_email: הקוד נשלח לדוא"ל שלך. @@ -7457,6 +7467,7 @@ nl: &nl profile_details: Profielgegevens sign_up_with_google: Registreren met Google sign_up_with_microsoft: Registreren met Microsoft + redirecting_to_provider: Doorverwijzen naar %{provider}... by_creating_an_account_you_agree_to_our_html: Door een account aan te maken gaat u akkoord met ons Privacybeleid en onze Servicevoorwaarden. enter_email_to_continue: Voer e-mailadres in om door te gaan account: Account @@ -8430,6 +8441,7 @@ ar: profile_details: تفاصيل الملف الشخصي sign_up_with_google: الاشتراك باستخدام جوجل sign_up_with_microsoft: الاشتراك باستخدام مايكروسوفت + redirecting_to_provider: جارٍ إعادة التوجيه إلى %{provider}... by_creating_an_account_you_agree_to_our_html: 'من خلال إنشاء حساب، فإنك توافق على سياسة الخصوصية وشروط الخدمة الخاصة بنا.' enter_email_to_continue: أدخل البريد الإلكتروني للمتابعة the_code_has_been_sent_to_your_email: تم إرسال الرمز إلى بريدك الإلكتروني. @@ -8541,6 +8553,7 @@ ko: profile_details: 프로필 세부 정보 sign_up_with_google: Google로 가입 sign_up_with_microsoft: Microsoft로 가입 + redirecting_to_provider: "%{provider}(으)로 이동 중..." by_creating_an_account_you_agree_to_our_html: '계정을 생성함으로써, 개인정보 처리방침서비스 약관에 동의하게 됩니다.' enter_email_to_continue: 계속하려면 이메일을 입력하세요 the_code_has_been_sent_to_your_email: 코드가 이메일로 전송되었습니다. @@ -8652,6 +8665,7 @@ ja: profile_details: プロフィールの詳細 sign_up_with_google: Googleで登録 sign_up_with_microsoft: Microsoftで登録 + redirecting_to_provider: "%{provider} にリダイレクトしています..." by_creating_an_account_you_agree_to_our_html: 'プライバシーポリシーおよび利用規約に同意の上、アカウントを作成します。' enter_email_to_continue: 続行するにはメールを入力してください the_code_has_been_sent_to_your_email: コードがあなたのメールに送信されました From 81a306928c1b943f3d1336fb32f0cd3b6acbb063 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 16 Aug 2026 10:03:28 +0300 Subject: [PATCH 17/18] fix ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11987880..77e73d98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: rspec: name: RSpec runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 container: image: ruby:4.0.5-alpine From 69343b1bdd69922f79715a005f63a85bd1965cec Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 17 Aug 2026 08:46:23 +0300 Subject: [PATCH 18/18] fix send completed emails --- app/jobs/process_submitter_completion_job.rb | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 50375483..a6c02dc6 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -123,13 +123,18 @@ class ProcessSubmitterCompletionJob user = submission.created_by_user || template.author + copy_email_configs = AccountConfigs.find_or_initialize_for_key(submitter.account, + AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) + + is_copy_email_enabled = documents_copy_email_enabled?(submitter, copy_email_configs) + if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false && (!template || template.preferences['completed_notification_email_enabled'] != false) user_submitter = submission.submitters.find { |s| s.email == user.email } is_sent_to_user = if user.role != 'integration' && - (!user_submitter || user_submitter.preferences['send_email'] == false) && + (!user_submitter || user_submitter.preferences['send_email'] == false || !is_copy_email_enabled) && user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false SubmitterMailer.completed_email(submitter, user).deliver_later! @@ -139,7 +144,7 @@ class ProcessSubmitterCompletionJob enqueue_bcc_completed_emails(submitter, user, is_sent_to_user) end - maybe_enqueue_copy_emails(submitter) + enqueue_copy_emails(submitter, copy_email_configs) if is_copy_email_enabled end def enqueue_bcc_completed_emails(submitter, user, is_sent_to_user) @@ -154,14 +159,13 @@ class ProcessSubmitterCompletionJob end end - def maybe_enqueue_copy_emails(submitter) - return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false + def documents_copy_email_enabled?(submitter, configs) + return false if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false - configs = AccountConfigs.find_or_initialize_for_key(submitter.account, - AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) - - return if configs.value['enabled'] == false + configs.value['enabled'] != false + end + def enqueue_copy_emails(submitter, configs) to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false } .sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)