- <% 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/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 = ''
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index a14d53aa..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
@@ -596,6 +597,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.
@@ -1338,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
@@ -1706,6 +1711,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.
@@ -2459,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
@@ -2827,6 +2836,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."
@@ -3566,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
@@ -3934,6 +3947,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é.
@@ -4683,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
@@ -5051,6 +5068,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.
@@ -5803,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
@@ -6171,6 +6192,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.
@@ -6790,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.
@@ -6929,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: Код було надіслано на вашу електронну пошту.
@@ -7068,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.
@@ -7193,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: הקוד נשלח לדוא"ל שלך.
@@ -7439,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
@@ -7807,6 +7836,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.
@@ -8409,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: تم إرسال الرمز إلى بريدك الإلكتروني.
@@ -8520,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: 코드가 이메일로 전송되었습니다.
@@ -8631,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: コードがあなたのメールに送信されました
diff --git a/lib/pdf_utils.rb b/lib/pdf_utils.rb
index 083ec47d..71595479 100644
--- a/lib/pdf_utils.rb
+++ b/lib/pdf_utils.rb
@@ -15,34 +15,28 @@ 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
- 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
diff --git a/lib/pdfium.rb b/lib/pdfium.rb
index e7c5ec40..470c2ccf 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,27 @@ 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 :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
+ 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
@@ -190,6 +215,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
@@ -243,6 +276,81 @@ 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
+
+ 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
+ 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_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
+ 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
@@ -253,6 +361,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,
@@ -267,6 +382,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
@@ -307,17 +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
+ 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
@@ -338,6 +446,7 @@ class Pdfium
}.freeze
class PdfiumError < StandardError; end
+ class PasswordError < PdfiumError; end
def self.error_message(code)
PDFIUM_ERRORS[code] || "Unknown error code: #{code}"
@@ -347,12 +456,68 @@ 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()
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
@@ -365,6 +530,7 @@ class Pdfium
@document_ptr = document_ptr
@pages = {}
+ @annot_counts = {}
@closed = false
@source_buffer = source_buffer
@form_handle = FFI::Pointer::NULL
@@ -389,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!
@@ -396,6 +570,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
@@ -464,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|
@@ -519,6 +703,80 @@ 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 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
+
+ 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,6 +847,71 @@ 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
attr_reader :document, :page_index, :page_ptr
@@ -619,6 +942,40 @@ class Pdfium
@height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr)
end
+ def annotations
+ ensure_not_closed!
+
+ @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
+
+ def objects
+ ensure_not_closed!
+
+ @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
+
def rotation
@rotation ||= Pdfium.FPDFPage_GetRotation(@page_ptr)
end
@@ -629,6 +986,26 @@ class Pdfium
@rotation = value
end
+ def box
+ @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?
+
+ Pdfium.FORM_OnAfterLoadPage(@page_ptr, form_handle)
+
+ @page_view = true
+ end
+
def closed?
@closed
end
@@ -790,7 +1167,7 @@ class Pdfium
@document.add_presave_hook(:cleanup) { @document.cleanup }
- reset_text_memoization
+ reset_memoization
nil
end
@@ -888,7 +1265,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)
@@ -1005,12 +1382,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)
@@ -1392,6 +1771,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
@@ -1406,23 +1799,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
@@ -1431,6 +1833,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
@@ -1457,6 +1878,423 @@ 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 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
+
+ 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 do |buffer, length|
+ Pdfium.FPDFAnnot_GetFormFieldValueRaw(form_handle, annot_ptr, buffer, length)
+ end
+ end
+
+ def export_value
+ read_wide do |buffer, length|
+ Pdfium.FPDFAnnot_GetFormFieldExportValueRaw(form_handle, annot_ptr, buffer, length)
+ end
+ 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_IsCheckedRaw(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
+
+ 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(&)
+ 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.fix_form_fields
+
+ @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..7aad6492
--- /dev/null
+++ b/lib/templates/build_pdfium_annotations.rb
@@ -0,0 +1,53 @@
+# 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
+ rescue StandardError => e
+ Rollbar.error(e) if defined?(Rollbar)
+
+ raise if Rails.env.development?
+
+ []
+ 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/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/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
new file mode 100644
index 00000000..d9133aaa
--- /dev/null
+++ b/lib/templates/find_pdfium_acro_fields.rb
@@ -0,0 +1,230 @@
+# frozen_string_literal: true
+
+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, data)
+ return [] if !doc.form? && data.exclude?('/Form')
+
+ 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) }
+ rescue StandardError => e
+ Rollbar.error(e) if defined?(Rollbar)
+
+ raise if Rails.env.development?
+
+ []
+ 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, widgets.first)
+ 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.presence
+ }
+ else
+ {
+ **attrs,
+ type: 'checkbox',
+ default_value: checked.present?
+ }
+ end
+ end
+
+ 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: 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.presence }
+ 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.presence }
+ else
+ { **attrs, type: 'text', default_value: field.value.presence }
+ 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.presence
+ }
+ end
+ 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)
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
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