Created
February 3, 2026 20:35
-
-
Save bogdan/d6a3e24bda6edb610951d0bba22db4a9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # frozen_string_literal: true | |
| require "date" | |
| require "hexapdf" | |
| require "pathname" | |
| require "stringio" | |
| require "tempfile" | |
| require 'furi' | |
| module PDFUtils | |
| MIME_TYPE = "application/pdf" | |
| APPROX_FILE_SIZE = 160_000 | |
| LICENSE_TEMPLATE_NAME = "license-v3" | |
| W8_TEMPLATE_NAME = "fw8ben" | |
| W9_TEMPLATE_NAME = "fw9" | |
| TEMPLATE_CONFIG = { | |
| "fw8ben" => { | |
| title: "W-8BEN Tax Form", | |
| requires_address: true, | |
| required_profile_fields: %i[legal_name taxid birthday], | |
| signature_field: "part3_signature", | |
| fields: { | |
| "1_name": "topmostSubform[0].Page1[0].f_1[0]", | |
| "2_country": "topmostSubform[0].Page1[0].f_2[0]", | |
| "3_address": "topmostSubform[0].Page1[0].f_3[0]", | |
| "3_city": "topmostSubform[0].Page1[0].f_4[0]", | |
| "3_country": "topmostSubform[0].Page1[0].f_5[0]", | |
| "4_mailing_address": "topmostSubform[0].Page1[0].f_6[0]", | |
| "4_city": "topmostSubform[0].Page1[0].f_7[0]", | |
| "4_country": "topmostSubform[0].Page1[0].f_8[0]", | |
| "5_us_taxid": "topmostSubform[0].Page1[0].f_9[0]", | |
| "6a_foreign_taxid": "topmostSubform[0].Page1[0].f_10[0]", | |
| "6b_tin_required": "topmostSubform[0].Page1[0].c1_01[0]", | |
| "7_reference_numbres": "topmostSubform[0].Page1[0].f_11[0]", | |
| "8_birthday": "topmostSubform[0].Page1[0].f_12[0]", | |
| "9_resident": "topmostSubform[0].Page1[0].f_13[0]", | |
| "10_name": "topmostSubform[0].Page1[0].f_14[0]", | |
| "10_percentage": "topmostSubform[0].Page1[0].f_15[0]", | |
| "10_income": "topmostSubform[0].Page1[0].f_16[0]", | |
| "10_rate": "topmostSubform[0].Page1[0].f_17[0]", | |
| "10_amount": "topmostSubform[0].Page1[0].f_18[0]", | |
| "part3_confirmation": "topmostSubform[0].Page1[0].c1_02[0]", | |
| "part3_signature": "topmostSubform[0].Page1[0].f_20[0]", | |
| "part3_date": "topmostSubform[0].Page1[0].Date[0]", | |
| "part3_name": "topmostSubform[0].Page1[0].f_21[0]", | |
| } | |
| }, | |
| "fw9" => { | |
| title: "W-9 Tax Form", | |
| requires_address: true, | |
| required_profile_fields: %i[legal_name taxid], | |
| signature_field: "part2_signature", | |
| fields: { | |
| "1_name": "topmostSubform[0].Page1[0].f1_01[0]", | |
| "2_business_name": "topmostSubform[0].Page1[0].f1_02[0]", | |
| "3a_invidual": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]", | |
| "3a_c_corporation": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[1]", | |
| "3a_s_corporation": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[2]", | |
| "3a_partnership": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[3]", | |
| "3a_trust": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[4]", | |
| "3a_llc": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[5]", | |
| "3a_tax_classification": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].f1_03[0]", | |
| "3a_other": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[6]", | |
| "3a_corporation_type": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].f1_04[0]", | |
| "3b": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_2[0]", | |
| "4_payee_code": "topmostSubform[0].Page1[0].f1_05[0]", | |
| "4_fatca": "topmostSubform[0].Page1[0].f1_06[0]", | |
| "5_address": "topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_07[0]", | |
| "6_city_state_zip": "topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_08[0]", | |
| "6_requester_name": "topmostSubform[0].Page1[0].f1_09[0]", | |
| "7_account_numbers": "topmostSubform[0].Page1[0].f1_10[0]", | |
| part1_ssn_1: "topmostSubform[0].Page1[0].f1_11[0]", | |
| part1_ssn_2: "topmostSubform[0].Page1[0].f1_12[0]", | |
| part1_ssn_3: "topmostSubform[0].Page1[0].f1_13[0]", | |
| part1_ein_1: "topmostSubform[0].Page1[0].f1_14[0]", | |
| part1_ein_2: "topmostSubform[0].Page1[0].f1_15[0]", | |
| part2_signature: "SignatureField", | |
| part2_date: "DateField", | |
| }, | |
| }, | |
| "license-v3" => { | |
| title: "License Agreement", | |
| requires_address: false, | |
| required_profile_fields: %i[legal_name], | |
| signature_field: "signature", | |
| fields: { | |
| name: "name", | |
| date: "date", | |
| signature: "signature", | |
| } | |
| }, | |
| }.with_indifferent_access.freeze | |
| TEST_SIGNATURE_PATH = Rails.root.join("test/fixtures/files/signature.png") | |
| TEST_FILE_PATH = Rails.root.join("tmp/test-document.pdf") | |
| # Even in development, we use production links | |
| # To ensure external iframe PDF preview works locally. | |
| FAKE_DATA = "%PDF-1.4\nfake\n%%EOF\n" | |
| # 1x1 full transparency PNG | |
| BLANK_IMAGE = StringIO.new( | |
| "\x89PNG\r\n\x1A\n" \ | |
| "\x00\x00\x00\rIHDR" \ | |
| "\x00\x00\x00\x01\x00\x00\x00\x01" \ | |
| "\x08\x06\x00\x00\x00\x1F\x15\xC4\x89" \ | |
| "\x00\x00\x00\x0AIDAT" \ | |
| "\x78\x9C\x63\x00\x01\x00\x00\x05\x00\x01" \ | |
| "\x0D\x0A\x2D\xB4" \ | |
| "\x00\x00\x00\x00IEND\xAE\x42\x60\x82", | |
| "rb" | |
| ) | |
| module_function | |
| def preview_checksum(template_name) | |
| Digest::SHA256.file(preview_path(template_name)).hexdigest | |
| end | |
| def test_signature | |
| File.open(Rails.root.join("test/fixtures/files/signature.png")) | |
| end | |
| def fake_data_io | |
| StringIO.new(FAKE_DATA) | |
| end | |
| def write_test_license | |
| generate_license( | |
| name: "Bradley Kam", | |
| date: Date.today, | |
| signature: test_signature, | |
| ) do |pdf_io| | |
| File.binwrite(TEST_FILE_PATH, pdf_io.read) | |
| end | |
| end | |
| def write_test_w8 | |
| generate_io( | |
| W8_TEMPLATE_NAME, | |
| "1_name": "Bradley Kam", | |
| "2_country": "Singapore", | |
| "3_address": "111 Mockingbird Lane", | |
| "3_city": "Singapore", | |
| "3_country": "Singapore", | |
| "6a_foreign_taxid": "S12345678", | |
| "8_birthday": "02/03/2026", | |
| "part3_confirmation": true, | |
| "part3_signature": "Bradley Kam", | |
| "part3_date": "02/03/2026", | |
| "part3_name": "Bradley Kam", | |
| ) do |pdf_io| | |
| File.binwrite(TEST_FILE_PATH, pdf_io.read) | |
| end | |
| end | |
| def write_test_w9 | |
| generate_io( | |
| W9_TEMPLATE_NAME, | |
| "1_name": "Bradley Kam", | |
| "3a_invidual": true, | |
| "5_address": "111 Mockingbird Lane", | |
| "6_city_state_zip": "San Francisco, CA 94105", | |
| part1_ssn_1: "123", | |
| part1_ssn_2: "45", | |
| part1_ssn_3: "6789", | |
| part2_signature: test_signature, | |
| part2_date: "02/03/2026", | |
| ) do |pdf_io| | |
| File.binwrite(TEST_FILE_PATH, pdf_io.read) | |
| end | |
| end | |
| def generate_license(**fields, &block) | |
| generate_io("license-v3", **fields, &block) | |
| end | |
| def generate_document(template_name, **fields) | |
| doc = open_template(template_name) | |
| # ensure_font_in_acroform_dr(doc, :BrushScriptStd) | |
| form = doc.acro_form | |
| fields_map = TEMPLATE_CONFIG.fetch(template_name).fetch(:fields) | |
| raise "Form not found" unless form | |
| fields.each do |name, value| | |
| name = fields_map[name] if fields_map[name] | |
| next if value.nil? | |
| value = formatted_date(value) if value.is_a?(Date) | |
| if value.respond_to?(:to_io) | |
| replace_field(doc, form, name.to_s, value) | |
| else | |
| set_field_value(form, name.to_s, value) | |
| end | |
| end | |
| form.flatten | |
| # form.create_appearances | |
| doc | |
| end | |
| def ensure_font_in_acroform_dr(doc, font_key) | |
| form = doc.acro_form | |
| form[:DR] ||= {} | |
| form[:DR][:Font] ||= {} | |
| return if form[:DR][:Font].key?(font_key) | |
| any_font = nil | |
| form.each_field do |f| | |
| f.each_widget do |w| | |
| ap = w[:AP] | |
| next unless ap && ap[:N] | |
| n = ap[:N].is_a?(HexaPDF::Dictionary) ? ap[:N].values.first : ap[:N] | |
| res = n[:Resources] | |
| fonts = res && res[:Font] | |
| next unless fonts && fonts[font_key] | |
| any_font = fonts[font_key] | |
| break | |
| end | |
| break if any_font | |
| end | |
| raise "Font #{font_key} not found in widget resources" unless any_font | |
| form[:DR][:Font][font_key] = any_font | |
| end | |
| def generate_io(template_name, **fields, &block) | |
| raise ArgumentError, "generate_io requires a block" unless block | |
| signature = fields[:signature] | |
| if signature.respond_to?(:content_type) && signature.content_type != "image/png" | |
| raise ArgumentError, "Signature content type is invalid." | |
| end | |
| Tempfile.create(["license", ".pdf"]) do |tempfile| | |
| tempfile.binmode | |
| doc = generate_document(template_name, **fields) | |
| doc.write(tempfile, optimize: true) | |
| tempfile.rewind | |
| return yield tempfile | |
| end | |
| end | |
| def generate_previews | |
| TEMPLATE_CONFIG.each_key do |template_name| | |
| doc = open_template(template_name) | |
| form = doc.acro_form | |
| fields = TEMPLATE_CONFIG.fetch(template_name).fetch(:fields) | |
| fields = fields.values if fields.is_a?(Hash) | |
| fields.each do |name| | |
| replace_field(doc, form, name, BLANK_IMAGE) | |
| end | |
| doc.catalog[:AcroForm].delete(:Fields) | |
| doc.catalog.delete(:AcroForm) | |
| preview_path = preview_path(template_name) | |
| FileUtils.mkdir_p(preview_path.dirname) | |
| doc.write(preview_path.to_s, optimize: true).first | |
| end | |
| end | |
| def preview_path(template_name) | |
| Rails.root.join("public/pdf", "#{template_name}.pdf") | |
| end | |
| def public_url(template_name) | |
| Furi.build(**ENV_URL_OPTIONS[:production], path: "/pdf/#{template_name}.pdf") | |
| end | |
| def preview_url(template_name) | |
| Furi.update( | |
| "https://mozilla.github.io/pdf.js/web/viewer.html", | |
| query: {file: public_url(template_name)}, | |
| anchor: "zoom=auto" | |
| ) | |
| end | |
| def open_template(template_name) | |
| HexaPDF::Document.open(Rails.root.join("app/pdf", "#{template_name}.pdf").to_s) | |
| end | |
| def formatted_date(value) | |
| value.strftime("%B %e, %Y") | |
| end | |
| def set_field_value(form, name, value) | |
| field = find_field(form, name) | |
| if value == true && field.is_a?(HexaPDF::Type::AcroForm::ButtonField) | |
| allowed_values = field.allowed_values | |
| value = allowed_values.first if allowed_values.present? | |
| end | |
| require 'debug' | |
| debugger if name == "topmostSubform[0].Page1[0].f_20[0]" | |
| field.field_value = value | |
| field.create_appearances(force: true) | |
| end | |
| def find_field(form, name) | |
| form.each_field.find { it.full_field_name.to_s == name } || | |
| raise("field not found #{name}") | |
| end | |
| def replace_field(doc, form, field, image_io) | |
| image_io = image_io.to_io if image_io.is_a?(ActionDispatch::Http::UploadedFile) | |
| input = find_field(form, field) | |
| widget = input.form_field.each_widget.first | |
| box = widget[:Rect] | |
| page = doc.pages.find do |p| | |
| p[:Annots]&.any? do |element| | |
| element.form_field&.full_field_name == field | |
| end | |
| end || raise("#{field} widget page not found") | |
| canvas = page.canvas(type: :overlay) | |
| canvas.image(image_io, at: [box[0], box[1]], width: box[2] - box[0]) | |
| unless input.is_a?(HexaPDF::Type::AcroForm::ButtonField) | |
| input.field_value = "" | |
| end | |
| page[:Annots].each do |element| | |
| if element.form_field&.full_field_name == field | |
| page[:Annots].delete(element) | |
| end | |
| end | |
| form.delete_field(field) | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment