Skip to content

Instantly share code, notes, and snippets.

@bogdan
Created February 3, 2026 20:35
Show Gist options
  • Select an option

  • Save bogdan/d6a3e24bda6edb610951d0bba22db4a9 to your computer and use it in GitHub Desktop.

Select an option

Save bogdan/d6a3e24bda6edb610951d0bba22db4a9 to your computer and use it in GitHub Desktop.
# 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