phlex mf'in pdf! (#125)

This commit is contained in:
nora 2025-06-24 15:32:58 -04:00 committed by GitHub
parent 136088f9de
commit a93149380e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1405 additions and 1418 deletions

View file

@ -169,3 +169,5 @@ gem "literal", "~> 1.7"
gem "phlex-rails", "~> 2.2"
gem "xsv", "~> 1.3"
gem "phlex-pdf", "~> 0.1.2"

View file

@ -372,6 +372,9 @@ GEM
pg (1.5.9)
phlex (2.2.1)
zeitwerk (~> 2.7)
phlex-pdf (0.1.2)
matrix (~> 0.4)
prawn (~> 2.0)
phlex-rails (2.2.0)
phlex (~> 2.2.1)
railties (>= 7.1, < 9)
@ -667,6 +670,7 @@ DEPENDENCIES
oauth2 (~> 2.0)
parallel (~> 1.26)
pg (~> 1.1)
phlex-pdf (~> 0.1.2)
phlex-rails (~> 2.2)
prawn (~> 2.5)
propshaft

View file

@ -19,7 +19,7 @@ class LettersController < ApplicationController
# GET /letters/1
def show
authorize @letter
@available_templates = SnailMail::Service.available_templates
@available_templates = SnailMail::PhlexService.available_templates
end
# GET /letters/new
@ -114,7 +114,7 @@ class LettersController < ApplicationController
authorize @letter, :preview_template?
template = params["template"]
include_qr_code = params["qr"].present?
send_data SnailMail::Service.generate_label(@letter, { template:, include_qr_code: }).render, type: "application/pdf", disposition: "inline"
send_data SnailMail::PhlexService.generate_label(@letter, { template:, include_qr_code: }).render, type: "application/pdf", disposition: "inline"
end
# POST /letters/1/mark_printed

View file

@ -1,228 +0,0 @@
module SnailMail
class BaseTemplate
include SnailMail::Helpers
# Template sizes in points [width, height]
SIZES = {
standard: [6 * 72, 4 * 72], # 4x6 inches (432 x 288 points)
envelope: [9.5 * 72, 4.125 * 72], # #10 envelope (684 x 297 points)
half_letter: [8 * 72, 5 * 72], # half-letter (576 x 360 points)
}.freeze
# Template metadata - override in subclasses
def self.template_name
name.demodulize.underscore.sub(/_template$/, "")
end
def self.template_size
:standard # default to 4x6 standard
end
def self.show_on_single?
false
end
def self.template_description
"A label template"
end
# Instance methods
attr_reader :options
def initialize(options = {})
@options = options
end
# Size in points [width, height]
def size
SIZES[self.class.template_size] || SIZES[:standard]
end
# Main render method, to be implemented by subclasses
def render(pdf, letter)
raise NotImplementedError, "Subclasses must implement the render method"
end
protected
# Helper methods for templates
# Render return address
def render_return_address(pdf, letter, x, y, width, height, options = {})
default_options = {
font: "arial",
size: 11,
align: :left,
valign: :top,
overflow: :shrink_to_fit,
min_font_size: 6,
}
options = default_options.merge(options)
font_name = options.delete(:font)
pdf.font(font_name) do
pdf.text_box(
format_return_address(letter, options[:no_name_line]),
at: [x, y],
width: width,
height: height,
**options,
)
end
end
# Render destination address
def render_destination_address(pdf, letter, x, y, width, height, options = {})
default_options = {
font: "f25",
size: 11,
align: :left,
valign: :center,
overflow: :shrink_to_fit,
min_font_size: 6,
disable_wrap_by_char: true,
}
options = default_options.merge(options)
font_name = options.delete(:font)
stroke = options.delete(:dbg_stroke)
pdf.font(font_name) do
pdf.text_box(
letter.address.snailify(letter.return_address.country),
at: [x, y],
width: width,
height: height,
**options,
)
end
if stroke
pdf.stroke { pdf.rectangle([x, y], width, height) }
end
end
# Render Intelligent Mail barcode
def render_imb(pdf, letter, x, y, width, options = {})
# we want an IMb if:
# - the letter is US-to-US (end-to-end IV)
# - the letter is US-to-non-US (IV until it's out of the US)
# - the letter is non-US-to-US (IV after it enters the US)
# but not if
# - the letter is non-US-to-non-US (that'd be pretty stupid)
return unless letter.address.us? || letter.return_address.us?
default_options = {
font: "imb",
size: 24,
align: :center,
overflow: :shrink_to_fit,
}
options = default_options.merge(options)
font_name = options.delete(:font)
pdf.font(font_name) do
pdf.text_box(
generate_imb(letter),
at: [x, y],
width: width,
disable_wrap_by_char: true,
**options,
)
end
end
# Render QR code
def render_qr_code(pdf, letter, x, y, size = 70)
return unless options[:include_qr_code]
SnailMail::QRCodeGenerator.generate_qr_code(pdf, "https://hack.club/#{letter.public_id}", x, y, size)
pdf.font("f25") do
pdf.text_box("scan this so we know you got it!", at: [x + 3, y + 22], width: 54, size: 6.4)
end
end
def render_letter_id(pdf, letter, x, y, size, opts = {})
return if options[:include_qr_code]
pdf.font(opts.delete(:font) || "f25") { pdf.text_box(letter.public_id, at: [x, y], size:, overflow: :shrink_to_fit, valign: :top, **opts) }
end
private
# Format destination address
def format_destination_address(letter)
letter.address.snailify(letter.return_address.country)
end
# Generate IMb barcode
def generate_imb(letter)
# Use the IMb module to generate the barcode
IMb.new(letter).generate
end
def render_postage(pdf, letter, x = pdf.bounds.right - 138)
if letter.postage_type == "indicia"
IMI.render_indicium(pdf, letter, letter.usps_indicium, x)
FIM.render_fim_d(pdf, x - 62)
elsif letter.postage_type == "stamps"
postage_amount = letter.postage
stamps = USPS::McNuggetEngine.find_stamp_combination(postage_amount)
requested_stamps = if stamps.size == 1
stamp = stamps.first
"#{stamp[:count]} #{stamp[:name]}"
elsif stamps.size == 2
"#{stamps[0][:count]} #{stamps[0][:name]} and #{stamps[1][:count]} #{stamps[1][:name]}"
else
stamps.map.with_index do |stamp, index|
if index == stamps.size - 1
"and #{stamp[:count]} #{stamp[:name]}"
else
"#{stamp[:count]} #{stamp[:name]}"
end
end.join(", ")
end
postage_info = <<~EOT
i take #{ActiveSupport::NumberHelper.number_to_currency(postage_amount)} in postage, so #{requested_stamps}
EOT
pdf.bounding_box([pdf.bounds.right - 55, pdf.bounds.top - 5], width: 50, height: 50) do
pdf.font("f25") do
pdf.text_box(
postage_info,
at: [1, 48],
width: 48,
height: 45,
size: 8,
align: :center,
min_font_size: 4,
overflow: :shrink_to_fit,
)
end
end
else
pdf.bounding_box([pdf.bounds.right - 55, pdf.bounds.top - 5], width: 52, height: 50) do
pdf.font("f25") do
pdf.text_box("please affix however much postage your post would like", at: [1, 48], width: 50, height: 45, size: 8, align: :center, min_font_size: 4, overflow: :shrink_to_fit)
end
end
end
end
def format_return_address(letter, no_name_line = false)
return_address = letter.return_address
return "No return address" unless return_address
<<~EOA
#{letter.return_address_name_line unless no_name_line}
#{[return_address.line_1, return_address.line_2].compact_blank.join("\n")}
#{return_address.city}, #{return_address.state} #{return_address.postal_code}
#{return_address.country if return_address.country != letter.address.country}
EOA
.strip
end
end
end

View file

@ -0,0 +1,21 @@
require_relative "components/base_component"
require_relative "components/page_component"
require_relative "components/template_base"
require_relative "components/half_letter_component"
# Individual render components
require_relative "components/return_address_component"
require_relative "components/destination_address_component"
require_relative "components/imb_component"
require_relative "components/qr_code_component"
require_relative "components/letter_id_component"
require_relative "components/postage_component"
require_relative "components/speech_bubble_component"
require_relative "components/registry"
module SnailMail
module Components
# This module serves as the entry point for all Phlex::PDF components
end
end

View file

@ -0,0 +1,80 @@
module SnailMail
module Components
class BaseComponent < Phlex::PDF
include SnailMail::Helpers
# Template sizes in points [width, height]
SIZES = {
standard: [6 * 72, 4 * 72], # 4x6 inches (432 x 288 points)
envelope: [9.5 * 72, 4.125 * 72], # #10 envelope (684 x 297 points)
half_letter: [8 * 72, 5 * 72], # half-letter (576 x 360 points)
}.freeze
# Template configuration methods - can be overridden in subclasses
def self.template_name
name.demodulize.underscore.sub(/_component$/, "")
end
def self.template_size
:standard # default to 4x6 standard
end
def self.show_on_single?
false
end
def self.template_description
"A label template"
end
attr_reader :letter, :options
def initialize(letter:, **options)
@letter = letter
@options = options
super()
end
# Size in points [width, height]
def size
SIZES[self.class.template_size] || SIZES[:standard]
end
# Override in subclasses to define the template
def view_template
raise NotImplementedError, "Subclasses must implement view_template"
end
protected
# Helper methods to render components
def render_return_address(x, y, width, height, **options)
render ReturnAddressComponent.new(letter: letter, x: x, y: y, width: width, height: height, **options)
end
def render_destination_address(x, y, width, height, **options)
render DestinationAddressComponent.new(letter: letter, x: x, y: y, width: width, height: height, **options)
end
def render_imb(x, y, width, **options)
render ImbComponent.new(letter: letter, x: x, y: y, width: width, **options)
end
def render_qr_code(x, y, size = 70)
render QrCodeComponent.new(letter: letter, x: x, y: y, size: size, **options)
end
def render_letter_id(x, y, size, **opts)
render LetterIdComponent.new(letter: letter, x: x, y: y, size: size, **opts.merge(options))
end
def render_postage(x = nil)
render PostageComponent.new(letter: letter, x: x, **options)
end
def render_speech_bubble(**opts)
render SpeechBubbleComponent.new(letter: letter, **opts.merge(options))
end
end
end
end

View file

@ -0,0 +1,43 @@
module SnailMail
module Components
class DestinationAddressComponent < BaseComponent
def initialize(letter:, x:, y:, width:, height:, **options)
@x = x
@y = y
@width = width
@height = height
super(letter: letter, **options)
end
def view_template
default_options = {
font: "f25",
size: 11,
align: :left,
valign: :center,
overflow: :shrink_to_fit,
min_font_size: 6,
disable_wrap_by_char: true,
}
opts = default_options.merge(options)
font_name = opts.delete(:font)
stroke_box = opts.delete(:dbg_stroke)
font(font_name) do
text_box(
letter.address.snailify(letter.return_address.country),
at: [@x, @y],
width: @width,
height: @height,
**opts,
)
end
if stroke_box
stroke { rectangle([@x, @y], @width, @height) }
end
end
end
end
end

View file

@ -0,0 +1,22 @@
module SnailMail
module Components
class HalfLetterComponent < TemplateBase
def self.abstract?
true
end
def self.template_size
:half_letter
end
def view_template
render_front
end
# Override in subclasses to define the front content
def render_front
raise NotImplementedError, "Subclasses must implement render_front"
end
end
end
end

View file

@ -0,0 +1,43 @@
module SnailMail
module Components
class ImbComponent < BaseComponent
def initialize(letter:, x:, y:, width:, **options)
@x = x
@y = y
@width = width
super(letter: letter, **options)
end
def view_template
# Only render IMB for appropriate US mail scenarios
return unless letter.address.us? || letter.return_address.us?
default_options = {
font: "imb",
size: 24,
align: :center,
overflow: :shrink_to_fit,
}
opts = default_options.merge(options)
font_name = opts.delete(:font)
font(font_name) do
text_box(
generate_imb(letter),
at: [@x, @y],
width: @width,
disable_wrap_by_char: true,
**opts,
)
end
end
private
def generate_imb(letter)
IMb.new(letter).generate
end
end
end
end

View file

@ -0,0 +1,27 @@
module SnailMail
module Components
class LetterIdComponent < BaseComponent
def initialize(letter:, x:, y:, size:, **options)
@x = x
@y = y
@size = size
super(letter: letter, **options)
end
def view_template
return if options[:include_qr_code]
font(options[:font] || "f25") do
text_box(
letter.public_id,
at: [@x, @y],
size: @size,
overflow: :shrink_to_fit,
valign: :top,
**options.except(:font)
)
end
end
end
end
end

View file

@ -0,0 +1,28 @@
module SnailMail
module Components
class PageComponent < BaseComponent
def before_template
start_new_page unless page_number > 0
register_fonts
fallback_fonts(["arial", "noto"])
end
private
def register_fonts
font_families.update(
"comic" => { normal: font_path("comic sans.ttf") },
"arial" => { normal: font_path("arial.otf") },
"f25" => { normal: font_path("f25.ttf") },
"imb" => { normal: font_path("imb.ttf") },
"gohu" => { normal: font_path("gohu.ttf") },
"noto" => { normal: font_path("noto sans regular.ttf") },
)
end
def font_path(font_name)
File.join(Rails.root, "app", "lib", "snail_mail", "assets", "fonts", font_name)
end
end
end
end

View file

@ -0,0 +1,82 @@
module SnailMail
module Components
class PostageComponent < BaseComponent
def initialize(letter:, x: nil, **options)
@x = x
super(letter: letter, **options)
end
def view_template
x_position = @x || (bounds.right - 138)
if letter.postage_type == "indicia"
IMI.render_indicium(self, letter, letter.usps_indicium, x_position)
FIM.render_fim_d(self, x_position - 62)
elsif letter.postage_type == "stamps"
render_stamps_postage(x_position)
else
render_generic_postage
end
end
private
def render_stamps_postage(x_position)
postage_amount = letter.postage
stamps = USPS::McNuggetEngine.find_stamp_combination(postage_amount)
requested_stamps = format_stamps_text(stamps)
postage_info = "i take #{ActiveSupport::NumberHelper.number_to_currency(postage_amount)} in postage, so #{requested_stamps}"
bounding_box([bounds.right - 55, bounds.top - 5], width: 50, height: 50) do
font("f25") do
text_box(
postage_info,
at: [1, 48],
width: 48,
height: 45,
size: 8,
align: :center,
min_font_size: 4,
overflow: :shrink_to_fit,
)
end
end
end
def render_generic_postage
bounding_box([bounds.right - 55, bounds.top - 5], width: 52, height: 50) do
font("f25") do
text_box(
"please affix however much postage your post would like",
at: [1, 48],
width: 50,
height: 45,
size: 8,
align: :center,
min_font_size: 4,
overflow: :shrink_to_fit
)
end
end
end
def format_stamps_text(stamps)
if stamps.size == 1
stamp = stamps.first
"#{stamp[:count]} #{stamp[:name]}"
elsif stamps.size == 2
"#{stamps[0][:count]} #{stamps[0][:name]} and #{stamps[1][:count]} #{stamps[1][:name]}"
else
stamps.map.with_index do |stamp, index|
if index == stamps.size - 1
"and #{stamp[:count]} #{stamp[:name]}"
else
"#{stamp[:count]} #{stamp[:name]}"
end
end.join(", ")
end
end
end
end
end

View file

@ -0,0 +1,22 @@
module SnailMail
module Components
class QrCodeComponent < BaseComponent
def initialize(letter:, x:, y:, size: 70, **options)
@x = x
@y = y
@size = size
super(letter: letter, **options)
end
def view_template
return unless options[:include_qr_code]
SnailMail::QRCodeGenerator.generate_qr_code(self, "https://hack.club/#{letter.public_id}", @x, @y, @size)
font("f25") do
text_box("scan this so we know you got it!", at: [@x + 3, @y + 22], width: 54, size: 6.4)
end
end
end
end
end

View file

@ -0,0 +1,104 @@
module SnailMail
module Components
class Registry
class ComponentNotFoundError < StandardError; end
# Default component to use when none is specified
DEFAULT_TEMPLATE_NAME = "kestrel's heidi template!"
class << self
# Get all template classes using descendants, excluding abstract ones
def all
TemplateBase.descendants.reject(&:abstract?)
end
# Get a component class by name
def get_component_class(name)
component_name = name.to_sym
component_class = all.find { |c| c.template_name.to_sym == component_name }
component_class || raise(ComponentNotFoundError, "Component not found: #{name}")
end
# Get a component instance for a letter
def component_for(letter, options = {})
# Check if component name is specified in options
component_name = options[:template]&.to_sym
component_class = if component_name
# Find component by name
all.find { |c| c.template_name.to_sym == component_name }
else
# Use default
default_component_class
end
# Create a new instance of the component
component_class ||= default_component_class
component_class.new(letter: letter, **options)
end
# Get components by size
def components_by_size(size)
size_sym = size.to_sym
all.select { |c| c.template_size == size_sym }
end
# List all available component names
def available_templates
all.map { |c| c.template_name.to_sym }
end
def available_single_templates
all.select { |c| c.show_on_single? }.map { |c| c.template_name.to_sym }
end
# Check if a component exists
def template_exists?(name)
all.any? { |c| c.template_name.to_sym == name.to_sym }
end
# Get the default template name
def default_template
DEFAULT_TEMPLATE_NAME
end
# Get the default component class
def default_component_class
all.find { |c| c.template_name == DEFAULT_TEMPLATE_NAME }
end
# Get template info for all components
def template_info
all.map do |component_class|
{
name: component_class.template_name.to_sym,
size: component_class.template_size,
description: component_class.template_description,
is_default: component_class.template_name == DEFAULT_TEMPLATE_NAME,
}
end
end
# Get templates for a specific size
def templates_for_size(size)
components = components_by_size(size)
Rails.logger.info "Components for size #{size}: Found #{components.count} components"
template_names = components.map do |component_class|
begin
name = component_class.template_name.to_s
Rails.logger.info " - Component: #{name}, Size: #{component_class.template_size}"
name
rescue => e
Rails.logger.error "Error getting component name: #{e.message}"
nil
end
end.compact
Rails.logger.info "Final template names for size #{size}: #{template_names.inspect}"
template_names
end
end
end
end
end

View file

@ -0,0 +1,52 @@
module SnailMail
module Components
class ReturnAddressComponent < BaseComponent
def initialize(letter:, x:, y:, width:, height:, **options)
@x = x
@y = y
@width = width
@height = height
super(letter: letter, **options)
end
def view_template
default_options = {
font: "arial",
size: 11,
align: :left,
valign: :top,
overflow: :shrink_to_fit,
min_font_size: 6,
}
opts = default_options.merge(options)
font_name = opts.delete(:font)
font(font_name) do
text_box(
format_return_address(letter, opts[:no_name_line]),
at: [@x, @y],
width: @width,
height: @height,
**opts,
)
end
end
private
def format_return_address(letter, no_name_line = false)
return_address = letter.return_address
return "No return address" unless return_address
<<~EOA
#{letter.return_address_name_line unless no_name_line}
#{[return_address.line_1, return_address.line_2].compact_blank.join("\n")}
#{return_address.city}, #{return_address.state} #{return_address.postal_code}
#{return_address.country if return_address.country != letter.address.country}
EOA
.strip
end
end
end
end

View file

@ -0,0 +1,32 @@
module SnailMail
module Components
class SpeechBubbleComponent < BaseComponent
def initialize(letter:, bubble_position:, bubble_width:, bubble_height:, bubble_radius: 10, tail_x:, tail_y:, tail_width:, line_width: 2.5, **options)
@bubble_position = bubble_position
@bubble_width = bubble_width
@bubble_height = bubble_height
@bubble_radius = bubble_radius
@tail_x = tail_x
@tail_y = tail_y
@tail_width = tail_width
@line_width = line_width
super(letter: letter, **options)
end
def view_template
# Draw the rounded rectangle bubble
self.line_width = @line_width
stroke do
rounded_rectangle(@bubble_position, @bubble_width, @bubble_height, @bubble_radius)
end
# Draw the speech tail
image(
image_path("speech-tail.png"),
at: [@tail_x, @tail_y],
width: @tail_width
)
end
end
end
end

View file

@ -0,0 +1,16 @@
module SnailMail
module Components
class TemplateBase < PageComponent
# Base class for all mail templates
# This allows us to use descendants to automatically discover templates
def self.abstract?
false
end
def self.template_description
"A mail template"
end
end
end
end

View file

@ -0,0 +1,57 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class AthenaStickersTemplate < TemplateBase
def self.template_name
"Athena stickers"
end
def self.show_on_single?
true
end
def view_template
render_return_address(5, bounds.top - 45, 190, 90, size: 8, font: "f25")
image(
image_path("athena/logo-stars.png"),
at: [5, bounds.top - 5],
width: 80,
)
render_destination_address(
104,
196,
256,
107,
size: 18, valign: :center, align: :left
)
render_speech_bubble(
bubble_position: [72, 202],
bubble_width: 306,
bubble_height: 122,
bubble_radius: 10,
tail_x: 96,
tail_y: 83,
tail_width: 32.2,
line_width: 2.5
)
image(
image_path("athena/nyc-orphy.png"),
at: [13, 98],
height: 97,
)
render_imb(230, 25, 190)
render_letter_id(3, 15, 8, rotate: 90)
render_qr_code(7, 160, 50)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class DinoWavingTemplate < TemplateBase
def self.template_name
"Dino Waving"
end
def self.show_on_single?
true
end
def view_template
image(
image_path("dino-waving.png"),
at: [333, 163],
width: 87,
)
# Render return address
render_return_address(10, 278, 260, 70, size: 10)
# Render destination address in speech bubble
render_destination_address(
88,
166,
236,
71,
size: 16, valign: :bottom, align: :left
)
# Render IMb barcode
render_imb(240, 24, 183)
render_qr_code(5, 65, 60)
render_letter_id(10, 19, 10)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class GoodJobTemplate < HalfLetterComponent
ADDRESS_FONT = "arial"
def self.template_name
"good job"
end
def self.template_size
:half_letter
end
def render_front
font "arial" do
text_box "good job", size: 99, at: [0, bounds.top], valign: :center, align: :center
end
text_box "from: @#{letter.metadata["gj_from"]}\n#{letter.metadata["gj_reason"]}", size: 18, at: [100, 100], align: :left
end
end
end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class HackatimeOTPTemplate < HalfLetterComponent
ADDRESS_FONT = "comic"
def self.template_name
"hackatime OTP"
end
def self.template_size
:half_letter
end
def render_front
move_down 100
text("Your Hackatime sign-in code is:", style: :bold, size: 30, align: :center)
move_down 10
text(letter.rubber_stamps || "mrrrrp :3", style: :bold, size: 80, align: :center)
text("This code will expire in 1 year.", size: 10, align: :center)
text("(that's #{1.year.from_now.in_time_zone("America/New_York").strftime("%-I:%M %p EST on %B %d")})", size: 9, align: :center, style: :italic)
stroke_rectangle([2, 55], bounds.width - 5, 52)
bounding_box([5, 50], width: bounds.width - 15, height: 49) do
self.line_width = 3
text("CONFIDENTIALITY NOTICE:", style: :bold, size: 8)
text("The information contained in this letter is intended only for the use of the individual named on the address side. It may contain information that is privileged, confidential, and exempt from disclosure under applicable law. If you are not the intended recipient, you are hereby notified that any disclosure, copying, or distribution of this information is prohibited. If you believe you have received this letter in error, please notify us immediately by return postcard and securely destroy the original letter.", size: 8)
end
end
end
end
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class HackatimeTemplate < TemplateBase
def self.template_name
"Hackatime (new)"
end
def view_template
image(
image_path("hackatime/its_about_time.png"),
at: [13, 219],
width: 409,
)
# Render return address
render_return_address(10, 278, 146, 70, font: "f25")
# Render destination address in speech bubble
render_destination_address(
80,
134,
290,
86,
size: 19, valign: :top, align: :left
)
# Render IMb barcode
render_imb(216, 25, 207)
render_letter_id(10, 19, 10)
render_qr_code(5, 55, 50)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,48 @@
module SnailMail
module Components
module Templates
class HCBStickersTemplate < TemplateBase
def self.template_name
"HCB Stickers"
end
def view_template
image(
image_path("lilia-hcb-stickers-bg.png"),
at: [0, 288],
width: 432,
)
# Render speech bubble
# image(
# image_path(speech_bubble_image),
# at: [speech_position[:x], speech_position[:y]],
# width: speech_position[:width]
# )
# Render return address
render_return_address(10, 278, 146, 70)
# Render destination address in speech bubble
render_destination_address(
192,
149,
226,
57,
size: 16,
valign: :bottom,
align: :left
)
# Render IMb barcode
render_imb(216, 25, 207)
render_letter_id(10, 12, 10)
render_qr_code(5, 196, 50)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class HCBWelcomePostcardTemplate < HalfLetterComponent
ADDRESS_FONT = "arial"
def self.template_name
"hcb welcome postcard"
end
SAMPLE_WELCOME_TEXT = "Hey!
I'm super excited to work with your org because I think whatever you do is a really important cause and it aligns perfectly with our mission to support things that we believe are good.
At HCB, we're all about empowering organizations like yours to make a real difference in the world. We believe in the power of community, innovation, and collaboration to create positive change. Your work resonates deeply with our values, and we can't wait to see the amazing things we'll accomplish together.
We're here to support you every step of the way. Whether you need technical assistance, community resources, or just someone to bounce ideas off of, our team is ready to help. We're not just a service provider we're your partner in making the world a better place.
Let's build something incredible together!
Warm regards,
The HCB Team"
def render_front
bounding_box([10, bounds.top - 10], width: bounds.width - 20, height: bounds.height - 20) do
image(image_path("hcb/hcb-icon.png"), width: 60)
text_box("Welcome to HCB!", size: 30, at: [70, bounds.top - 18])
end
bounding_box([20, bounds.top - 90], width: bounds.width - 40, height: bounds.height - 100) do
text(letter.rubber_stamps || "", size: 15, align: :justify, overflow: :shrink_to_fit)
end
end
end
end
end
end

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class HcpcxcTemplate < TemplateBase
def self.template_name
"hcpcxc"
end
def view_template
image(
image_path("dino-waving.png"),
at: [ 333, 163 ],
width: 87
)
image(
image_path("hcpcxc_ra.png"),
at: [ 5, 288-5 ],
width: 175
)
render_destination_address(
88,
166,
236,
71,
size: 16, valign: :bottom, align: :left
)
# Render IMb barcode
render_imb(240, 24, 183)
render_qr_code(5, 65, 60)
render_letter_id(10, 19, 10)
if letter.rubber_stamps.present?
font("arial") do
text_box(
letter.rubber_stamps,
at: [ 294, 220 ],
width: 255,
height: 21,
overflow: :shrink_to_fit,
disable_wrap_by_char: true,
min_size: 1
)
end
end
render_postage
end
end
end
end
end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class HeidiReadmeTemplate < TemplateBase
def self.template_name
"Heidi Can't Readme"
end
def self.show_on_single?
true
end
def view_template
render_return_address(10, 278, 190, 90, size: 12, font: "f25")
render_destination_address(
133,
176,
256,
107,
size: 18, valign: :center, align: :left
)
render_speech_bubble(
bubble_position: [90 + 20, 189 - 5],
bubble_width: 306,
bubble_height: 122,
bubble_radius: 10,
tail_x: 114 + 20,
tail_y: 70 - 5,
tail_width: 32.2,
line_width: 2.5
)
image(
image_path("msw-heidi-cant-readme.png"),
at: [6 + 20, 75],
width: 111,
)
render_imb(230, 25, 190)
render_letter_id(3, 15, 8, rotate: 90)
render_qr_code(7, 72 + 7 + 50 + 10 + 12, 60)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,50 @@
module SnailMail
module Components
module Templates
class JoyousCatTemplate < TemplateBase
def self.template_name
"Joyous Cat :3"
end
def self.show_on_single?
true
end
def view_template
render_speech_bubble(
bubble_position: [111, 189],
bubble_width: 306,
bubble_height: 122,
bubble_radius: 10,
tail_x: 208,
tail_y: 74,
tail_width: 106.4,
line_width: 3
)
image(
image_path("acon-joyous-cat.png"),
at: [208, 74],
width: 106.4,
)
render_return_address(10, 270, 130, 70)
render_destination_address(
134,
173,
266,
67,
size: 16,
valign: :center,
align: :left
)
render_imb(131, 100, 266)
render_qr_code(7, 72 + 7, 72)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,39 @@
module SnailMail
module Components
module Templates
class KestrelHeidiTemplate < TemplateBase
def self.template_name
"kestrel's heidi template!"
end
def self.show_on_single?
true
end
def view_template
image(
image_path("kestrel-mail-heidi.png"),
at: [107, 216],
width: 305,
)
render_return_address(10, 278, 190, 90, size: 14)
render_destination_address(
126,
201,
266,
67,
size: 16,
valign: :center,
align: :left
)
render_imb(124, 120, 200)
render_qr_code(7, 72 + 7, 72)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,49 @@
module SnailMail
module Components
module Templates
class MailOrpheusTemplate < TemplateBase
def self.template_name
"Mail Orpheus!"
end
def self.show_on_single?
true
end
def view_template
image(
image_path("eleeza-mail-orpheus.png"),
at: [320, 113],
width: 106.4,
)
# Render speech bubble
# image(
# image_path(speech_bubble_image),
# at: [speech_position[:x], speech_position[:y]],
# width: speech_position[:width]
# )
# Render return address
render_return_address(10, 270, 130, 70)
# Render destination address in speech bubble
render_destination_address(
79.5,
202,
237,
100,
size: 16, valign: :bottom, align: :left
)
# Render IMb barcode
render_imb(78, 102, 237)
# Render QR code for tracking
render_qr_code(7, 67, 60)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class SummerOfMakingFreeStickersTemplate < TemplateBase
def self.template_name
"SoM Free Stickers"
end
def self.show_on_single?
true
end
def view_template
render_return_address(5, bounds.top - 5, 190, 90, size: 8, font: "f25")
render_destination_address(
120,
115,
270,
81,
size: 18, valign: :center, align: :left
)
image(
image_path("som/banner.png"),
at: [-5, 288 - 56],
width: 445,
)
render_imb(245, 20, 170)
render_letter_id(3, 15, 8, rotate: 90)
render_qr_code(2, 52, 50)
render_postage
end
end
end
end
end

View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
module SnailMail
module Components
module Templates
class TarotTemplate < TemplateBase
def self.template_name
"Tarot"
end
def view_template
render_return_address(10, 278, 190, 90, size: 12, font: 'comic')
if letter.rubber_stamps.present?
font("gohu") do
text_box(
"\"#{letter.rubber_stamps}\"",
at: [ 137, 183 ],
width: 255,
height: 21,
overflow: :shrink_to_fit,
disable_wrap_by_char: true,
min_size: 1
)
end
end
render_destination_address(
137,
160,
255,
90,
size: 16, valign: :center, align: :left
)
stroke do
self.line_width = 1
line([ 137 - 25, 167 ], [ 392 + 25, 167 ])
end
render_speech_bubble(
bubble_position: [111, 189],
bubble_width: 306,
bubble_height: 122,
bubble_radius: 10,
tail_x: 118,
tail_y: 70,
tail_width: 32.2,
line_width: 2.5
)
image(
image_path("tarot/msw-joker.png"),
at: [ 6, 104 ],
width: 111
)
render_imb(216, 25, 207)
render_letter_id(3, 15, 8, rotate: 90)
render_qr_code(7, 72 + 7, 72)
render_postage
end
end
end
end
end

View file

@ -1,105 +0,0 @@
require "prawn"
require "securerandom"
require_relative "templates"
module SnailMail
class LabelGenerator
class Error < StandardError; end
attr_reader :options
def initialize(options = {})
@options = {
margin: 0,
}.merge(options)
end
# Generate labels for a collection of letters
# template_names: array of template names to cycle through
# Returns the PDF object directly (doesn't write to disk unless output_path provided)
def generate(letters, output_path = nil, template_names)
raise Error, "No letters provided" if letters.empty?
raise Error, "No template names provided" if template_names.empty?
begin
# Get template classes upfront to avoid repeated lookups
template_classes = template_names.map do |name|
Templates.get_template_class(name)
end
# Ensure all template sizes are the same
sizes = template_classes.map(&:template_size).uniq
if sizes.length > 1
raise Error, "Mixed template sizes in batch (#{sizes.join(", ")}). All templates must have the same size."
end
# Create template lookup for faster access
template_lookup = {}
template_names.each_with_index do |name, i|
template_lookup[name] = template_classes[i]
end
# All templates have the same size, create one PDF
pdf = create_document(sizes.first)
letters.each_with_index do |letter, index|
template_name = template_names[index % template_names.length]
render_letter(pdf, letter, template: template_name, template_class: template_lookup[template_name])
pdf.start_new_page unless index == letters.length - 1
end
# Write to disk only if output_path is provided
pdf.render_file(output_path) if output_path
# Return the PDF object
pdf
rescue => e
Rails.logger.error("Failed to generate labels: #{e.message}")
raise
end
end
private
def create_document(page_size_name)
page_size = BaseTemplate::SIZES[page_size_name] || BaseTemplate::SIZES[:standard]
pdf = Prawn::Document.new(
page_size: page_size,
margin: @options[:margin],
)
register_fonts(pdf)
pdf.fallback_fonts(["arial", "noto"])
pdf
end
def register_fonts(pdf)
pdf.font_families.update(
"comic" => { normal: font_path("comic sans.ttf") },
"arial" => { normal: font_path("arial.otf") },
"f25" => { normal: font_path("f25.ttf") },
"imb" => { normal: font_path("imb.ttf") },
"gohu" => { normal: font_path("gohu.ttf") },
"noto" => { normal: font_path("noto sans regular.ttf") },
)
end
def font_path(font_name)
File.join(Rails.root, "app", "lib", "snail_mail", "assets", "fonts", font_name)
end
def render_letter(pdf, letter, letter_options = {})
template_options = @options.merge(letter_options)
# Use pre-fetched template class if provided, otherwise look it up
if template_class = letter_options[:template_class]
template = template_class.new(template_options)
else
template = Templates.template_for(letter, template_options)
end
template.render(pdf, letter)
end
end
end

View file

@ -0,0 +1,165 @@
require_relative "components"
module SnailMail
class PhlexService
class Error < StandardError; end
# Generate a label for a single letter using Phlex::PDF
def self.generate_label(letter, options = {})
validate_letter(letter)
template_name = options.delete(:template) || default_template
# Get page size from component class
component_class = Components::Registry.get_component_class(template_name)
page_size = Components::BaseComponent::SIZES[component_class.template_size] || Components::BaseComponent::SIZES[:standard]
# Create component
component = Components::Registry.component_for(letter, options.merge(template: template_name))
# Use a simple wrapper approach - create the PDF and delegate methods to the component
class << component
attr_accessor :document
# Override method_missing to delegate to the document when needed
def method_missing(method_name, *args, **kwargs, &block)
if document && document.respond_to?(method_name)
document.send(method_name, *args, **kwargs, &block)
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
(document && document.respond_to?(method_name, include_private)) || super
end
end
# Create Prawn document and set it on the component
component.document = Prawn::Document.new(
page_size: page_size,
margin: options[:margin] || 0,
)
# Call the component's template methods
component.before_template if component.respond_to?(:before_template)
component.view_template
component.after_template if component.respond_to?(:after_template)
component.document
end
# Generate labels for a batch of letters using Phlex::PDF
def self.generate_batch_labels(letters, options = {})
validate_batch(letters)
template_cycle = options[:template_cycle]
validate_template_cycle(template_cycle) if template_cycle
# If no template cycle is provided, use the default template
template_cycle ||= [default_template]
# Get component classes once, avoid repeated lookups
component_classes = template_cycle.map do |name|
Components::Registry.get_component_class(name)
end
# Ensure all templates in the cycle are of the same size
template_sizes = component_classes.map(&:template_size).uniq
if template_sizes.length > 1
raise Error, "All templates in cycle must have the same size. Found: #{template_sizes.join(", ")}"
end
# Create combined document with proper page size
page_size = Components::BaseComponent::SIZES[template_sizes.first] || Components::BaseComponent::SIZES[:standard]
combined_pdf = Prawn::Document.new(
page_size: page_size,
margin: options[:margin] || 0,
)
letters.each_with_index do |letter, index|
template_name = template_cycle[index % template_cycle.length]
component = Components::Registry.component_for(letter, options.merge(template: template_name))
# Use the same method delegation approach as single label generation
class << component
attr_accessor :document
def method_missing(method_name, *args, **kwargs, &block)
if document && document.respond_to?(method_name)
document.send(method_name, *args, **kwargs, &block)
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
(document && document.respond_to?(method_name, include_private)) || super
end
end
# Start new page for subsequent letters
if index > 0
combined_pdf.start_new_page
end
# Set the document context and render
component.document = combined_pdf
component.before_template if component.respond_to?(:before_template)
component.view_template
component.after_template if component.respond_to?(:after_template)
end
combined_pdf
end
# List available templates
def self.available_templates
Components::Registry.available_templates.uniq
end
# Get a list of all templates with their metadata
def self.template_info
Components::Registry.template_info
end
# Get templates for a specific size
def self.templates_for_size(size)
Components::Registry.templates_for_size(size)
end
# Get the default template
def self.default_template
Components::Registry.default_template
end
# Check if templates exist
def self.templates_exist?(template_names)
Array(template_names).all? do |name|
Components::Registry.template_exists?(name)
end
end
private
def self.validate_letter(letter)
raise Error, "Letter cannot be nil" unless letter
raise Error, "Letter must have an address" unless letter.respond_to?(:address) && letter.address
end
def self.validate_batch(letters)
raise Error, "Letters cannot be nil" unless letters
raise Error, "Letters must be a collection" unless letters.respond_to?(:each)
raise Error, "Letters collection cannot be empty" if letters.empty?
end
def self.validate_template_cycle(template_cycle)
raise Error, "Template cycle must be an array" unless template_cycle.is_a?(Array)
raise Error, "Template cycle cannot be empty" if template_cycle.empty?
invalid_templates = template_cycle.reject { |name| templates_exist?([name]) }
if invalid_templates.any?
raise Error, "Invalid templates in cycle: #{invalid_templates.join(", ")}"
end
end
end
end

View file

@ -55,8 +55,8 @@ module SnailMail
usps_mailer_id = OpenStruct.new(mid: "111111")
Templates.available_templates.each do |name|
template = Templates.get_template_class(name)
SnailMail::Components::Registry.available_templates.each do |name|
template = SnailMail::Components::Registry.get_component_class(name)
sender, recipient = names.sample(2)
mock_letter = OpenStruct.new(
@ -81,7 +81,7 @@ module SnailMail
)
Rails.logger.info("generating preview for #{name}...")
pdf = SnailMail::Service.generate_label(mock_letter, template: name)
pdf = SnailMail::PhlexService.generate_label(mock_letter, template: name)
png_path = OUTPUT_DIR.join("#{template.name.split("::").last.underscore}.png")

View file

@ -1,121 +0,0 @@
require_relative "label_generator"
require_relative "templates"
module SnailMail
class Service
class Error < StandardError; end
# Generate a label for a single letter and attach it to the letter
def self.generate_label(letter, options = {})
validate_letter(letter)
template_name = options.delete(:template) || default_template
generator = LabelGenerator.new(options)
# Generate PDF without writing to disk
pdf = generator.generate([letter], nil, [template_name])
pdf
end
# Generate labels for a batch of letters and attach to batch
def self.generate_batch_labels(letters, options = {})
validate_batch(letters)
template_cycle = options[:template_cycle]
validate_template_cycle(template_cycle) if template_cycle
# If no template cycle is provided, use the default template
template_cycle ||= [default_template]
# Get template classes once, avoid repeated lookups
template_classes = template_cycle.map do |name|
Templates.get_template_class(name)
end
# Ensure all templates in the cycle are of the same size
template_sizes = template_classes.map(&:template_size).uniq
if template_sizes.length > 1
raise Error, "All templates in cycle must have the same size. Found: #{template_sizes.join(", ")}"
end
# Generate labels with template cycling without writing to disk
generator = LabelGenerator.new(options)
pdf = generator.generate(letters, nil, template_cycle)
pdf
end
# List available templates
def self.available_templates
Templates.available_templates.uniq
end
# Get a list of all templates with their metadata
def self.template_info
Templates.all.map do |template_class|
{
name: template_class.template_name.to_sym,
size: template_class.template_size,
description: template_class.template_description,
is_default: template_class == Templates::DEFAULT_TEMPLATE,
}
end
end
# Get templates for a specific size
def self.templates_for_size(size)
templates = Templates.templates_by_size(size)
Rails.logger.info "Templates for size #{size}: Found #{templates.count} templates"
template_names = templates.map do |template_class|
begin
name = template_class.template_name.to_s
Rails.logger.info " - Template: #{name}, Size: #{template_class.template_size}"
name
rescue => e
Rails.logger.error "Error getting template name: #{e.message}"
nil
end
end.compact
Rails.logger.info "Final template names for size #{size}: #{template_names.inspect}"
template_names
end
# Get the default template
def self.default_template
Templates::DEFAULT_TEMPLATE.template_name
end
# Check if templates exist
def self.templates_exist?(template_names)
Array(template_names).all? do |name|
Templates.template_exists?(name)
end
end
private
def self.validate_letter(letter)
raise Error, "Letter cannot be nil" unless letter
raise Error, "Letter must have an address" unless letter.respond_to?(:address) && letter.address
end
def self.validate_batch(letters)
raise Error, "Letters cannot be nil" unless letters
raise Error, "Letters must be a collection" unless letters.respond_to?(:each)
raise Error, "Letters collection cannot be empty" if letters.empty?
end
def self.validate_template_cycle(template_cycle)
raise Error, "Template cycle must be an array" unless template_cycle.is_a?(Array)
raise Error, "Template cycle cannot be empty" if template_cycle.empty?
invalid_templates = template_cycle.reject { |name| templates_exist?([name]) }
if invalid_templates.any?
raise Error, "Invalid templates in cycle: #{invalid_templates.join(", ")}"
end
end
end
end

View file

@ -1,86 +0,0 @@
module SnailMail
module Templates
class TemplateNotFoundError < StandardError; end
# All available templates hardcoded in a single array
TEMPLATES = [
JoyousCatTemplate,
MailOrpheusTemplate,
HCBStickersTemplate,
KestrelHeidiTemplate,
# HackatimeStickersTemplate,
TarotTemplate,
DinoWavingTemplate,
HcpcxcTemplate,
HackatimeTemplate,
HeidiReadmeTemplate,
GoodJobTemplate,
HackatimeOTPTemplate,
AthenaStickersTemplate,
HCBWelcomePostcardTemplate,
SummerOfMakingFreeStickersTemplate
].freeze
# Default template to use when none is specified
DEFAULT_TEMPLATE = KestrelHeidiTemplate
class << self
# Get all template classes
def all
TEMPLATES
end
# Get a template class by name
def get_template_class(name)
template_name = name.to_sym
template_class = TEMPLATES.find { |t| t.template_name.to_sym == template_name }
template_class || raise(TemplateNotFoundError, "Template not found: #{name}")
end
# Get a template instance for a letter
# Options:
# template: Specifies the template to use, overriding any template in letter.rubber_stamps
# template_class: Pre-fetched template class to use (fastest option)
def template_for(letter, options = {})
# First check if template_class is provided (fastest path)
if template_class = options[:template_class]
return template_class.new(options)
end
# Next check if template name is specified in options
template_name = options[:template]&.to_sym
template_class = if template_name
# Find template by name
TEMPLATES.find { |t| t.template_name.to_sym == template_name }
else
# Use default
DEFAULT_TEMPLATE
end
# Create a new instance of the template
template_class ? template_class.new(options) : DEFAULT_TEMPLATE.new(options)
end
# Get templates by size
def templates_by_size(size)
size_sym = size.to_sym
TEMPLATES.select { |t| t.template_size == size_sym }
end
# List all available template names
def available_templates
TEMPLATES.map { |t| t.template_name.to_sym }
end
def available_single_templates
TEMPLATES.select { |t| t.show_on_single? }.map { |t| t.template_name.to_sym }
end
# Check if a template exists
def template_exists?(name)
TEMPLATES.any? { |t| t.template_name.to_sym == name.to_sym }
end
end
end
end

View file

@ -1,53 +0,0 @@
module SnailMail
module Templates
class AthenaStickersTemplate < BaseTemplate
def self.template_name
"Athena stickers"
end
def self.show_on_single?
true
end
def render(pdf, letter)
render_return_address(pdf, letter, 5, pdf.bounds.top - 45, 190, 90, size: 8, font: "f25")
pdf.image(
image_path("athena/logo-stars.png"),
at: [5, pdf.bounds.top - 5],
width: 80,
)
render_destination_address(
pdf,
letter,
104,
196,
256,
107,
{ size: 18, valign: :center, align: :left }
)
pdf.stroke do
pdf.line_width = 2.5
pdf.rounded_rectangle([72, 202], 306, 122, 10)
end
pdf.image(
image_path("athena/nyc-orphy.png"),
at: [13, 98],
height: 97,
)
pdf.image(
image_path("speech-tail.png"),
at: [96, 83],
width: 32.2,
)
render_imb(pdf, letter, 230, 25, 190)
render_letter_id(pdf, letter, 3, 15, 8, rotate: 90)
render_qr_code(pdf, letter, 7, 160, 50)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,64 +0,0 @@
require_relative "../base_template"
module SnailMail
module Templates
class CharacterTemplate < BaseTemplate
# Abstract base class for character templates
def self.template_name
"character" # This template isn't meant to be used directly
end
def self.template_description
"Base class for character templates (not for direct use)"
end
attr_reader :character_image, :speech_bubble_image, :character_position, :speech_position
def initialize(options = {})
super
@character_image = options[:character_image]
@speech_bubble_image = options[:speech_bubble_image] || "speech_bubble.png"
@character_position = options[:character_position] || { x: 10, y: 100, width: 120 }
@speech_position = options[:speech_position] || { x: 100, y: 250, width: 300, height: 100 }
end
def render(pdf, letter)
# Render character
pdf.image(
image_path(character_image),
at: [ character_position[:x], character_position[:y] ],
width: character_position[:width]
)
# Render speech bubble
pdf.image(
image_path(speech_bubble_image),
at: [ speech_position[:x], speech_position[:y] ],
width: speech_position[:width]
)
# Render return address
render_return_address(pdf, letter, 10, 270, 130, 70)
# Render destination address in speech bubble
render_destination_address(
pdf,
letter,
speech_position[:x] + 20,
speech_position[:y] - 10,
speech_position[:width] - 40,
speech_position[:height] - 20,
{ size: 12, valign: :center }
)
# Render IMb barcode
render_imb(pdf, letter, 100, 90, 280, 30)
# Render QR code for tracking
render_qr_code(pdf, letter, 5, 65, 60)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,59 +0,0 @@
require_relative "../base_template"
module SnailMail
module Templates
class CorporateEnvelopeTemplate < BaseTemplate
def self.template_name
"corporate_envelope"
end
def self.template_size
:envelope # Use the envelope size from BaseTemplate::SIZES
end
def self.template_description
"Professional business envelope template"
end
def render(pdf, letter)
# Draw a subtle border
pdf.stroke do
pdf.rectangle [ 15, 4 * 72 - 15 ], 9.5 * 72 - 30, 4.125 * 72 - 30
end
# Render return address in top left
pdf.font("Helvetica") do
pdf.text_box(
format_return_address(letter),
at: [ 30, 4 * 72 - 30 ],
width: 250,
height: 60,
overflow: :shrink_to_fit,
min_font_size: 8,
style: :bold
)
end
# Render destination address
pdf.font("Helvetica") do
pdf.text_box(
format_destination_address(letter),
at: [ 4.5 * 72 - 200, 2.5 * 72 + 50 ],
width: 400,
height: 120,
overflow: :shrink_to_fit,
min_font_size: 10,
leading: 2
)
end
# Render IMb barcode at bottom
render_imb(pdf, letter, 72, 30, 7 * 72, 30)
# Render QR code in bottom left with smaller size
render_qr_code(pdf, letter, 25, 70, 50)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,41 +0,0 @@
module SnailMail
module Templates
class DinoWavingTemplate < BaseTemplate
def self.template_name
"Dino Waving"
end
def self.show_on_single?
true
end
def render(pdf, letter)
pdf.image(
image_path("dino-waving.png"),
at: [333, 163],
width: 87,
)
# Render return address
render_return_address(pdf, letter, 10, 278, 260, 70, size: 10)
# Render destination address in speech bubble
render_destination_address(
pdf,
letter,
88,
166,
236,
71,
{ size: 16, valign: :bottom, align: :left }
)
# Render IMb barcode
render_imb(pdf, letter, 240, 24, 183)
render_qr_code(pdf, letter, 5, 65, 60)
render_letter_id(pdf, letter, 10, 19, 10)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,46 +0,0 @@
require_relative "../base_template"
module SnailMail
module Templates
class EnvelopeTemplate < BaseTemplate
def self.template_name
"envelope"
end
def self.template_size
:envelope # Use the envelope size from BaseTemplate::SIZES
end
def self.template_description
"Standard #10 business envelope template"
end
def render(pdf, letter)
# Render return address in top left
render_return_address(pdf, letter, 15, 4 * 72 - 30, 250, 60)
# Render destination address centered
render_destination_address(
pdf,
letter,
4.5 * 72 - 150, # Centered horizontally
2.5 * 72, # Centered vertically
300, # Width
120, # Height
{
size: 12,
valign: :center,
align: :center
}
)
# Render IMb barcode at bottom
render_imb(pdf, letter, 72, 30, 7 * 72, 30)
# Render QR code in bottom left
render_qr_code(pdf, letter, 15, 70, 60)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
module SnailMail
module Templates
class GoodJobTemplate < HalfLetterTemplate
ADDRESS_FONT = "arial"
def self.template_name
"good job"
end
def self.template_size
:half_letter
end
def render_front(pdf, letter)
pdf.font "arial" do
pdf.text_box "good job", size: 99, at: [0, pdf.bounds.top], valign: :center, align: :center
end
pdf.text_box "from: @#{letter.metadata["gj_from"]}\n#{letter.metadata["gj_reason"]}", size: 18, at: [100, 100], align: :left
end
end
end
end

View file

@ -1,34 +0,0 @@
# frozen_string_literal: true
module SnailMail
module Templates
class HackatimeOTPTemplate < HalfLetterTemplate
ADDRESS_FONT = "comic"
def self.template_name
"hackatime OTP"
end
def self.template_size
:half_letter
end
def render_front(pdf, letter)
pdf.move_down 100
pdf.text("Your Hackatime sign-in code is:", style: :bold, size: 30, align: :center)
pdf.move_down 10
pdf.text(letter.rubber_stamps || "mrrrrp :3", style: :bold, size: 80, align: :center)
# pdf.move_up 30
pdf.text("This code will expire in 1 year.", size: 10, align: :center)
pdf.text("(that's #{1.year.from_now.in_time_zone("America/New_York").strftime("%-I:%M %p EST on %B %d")})", size: 9, align: :center, style: :italic)
pdf.stroke_rectangle([2, 55], pdf.bounds.width - 5, 52)
pdf.bounding_box([5, 50], width: pdf.bounds.width - 15, height: 49) do
pdf.line_width 3
pdf.text("CONFIDENTIALITY NOTICE:", style: :bold, size: 8)
pdf.text("The information contained in this letter is intended only for the use of the individual named on the address side. It may contain information that is privileged, confidential, and exempt from disclosure under applicable law. If you are not the intended recipient, you are hereby notified that any disclosure, copying, or distribution of this information is prohibited. If you believe you have received this letter in error, please notify us immediately by return postcard and securely destroy the original letter.", size: 8)
end
end
end
end
end

View file

@ -1,26 +0,0 @@
module SnailMail
module Templates
class HackatimeStickersTemplate < KestrelHeidiTemplate
MSG = <<~EOT
you're getting this because you coded for
15 minutes during Scrapyard and tracked
it w/ Hackatime V2! ^_^
zach sent you an email about it...
EOT
def self.template_name
"Hackatime Stickers"
end
def render(pdf, letter)
super
render_letter_id(pdf, letter, 360, 13, 12)
pdf.image(image_path("hackatime/badge.png"), at: [ 10, 92 ], width: 117)
pdf.font("gohu") do
pdf.text_box(MSG, at: [ 162, 278 ], size: 8)
end
end
end
end
end

View file

@ -1,46 +0,0 @@
module SnailMail
module Templates
class HackatimeTemplate < BaseTemplate
def self.template_name
"Hackatime (new)"
end
def render(pdf, letter)
pdf.image(
image_path("hackatime/its_about_time.png"),
at: [13, 219],
width: 409,
)
# Render speech bubble
# pdf.image(
# image_path(speech_bubble_image),
# at: [speech_position[:x], speech_position[:y]],
# width: speech_position[:width]
# )
# Render return address
render_return_address(pdf, letter, 10, 278, 146, 70, font: "f25")
# Render destination address in speech bubble
render_destination_address(
pdf,
letter,
80,
134,
290,
86,
{ size: 19, valign: :top, align: :left }
)
# Render IMb barcode
render_imb(pdf, letter, 216, 25, 207)
render_letter_id(pdf, letter, 10, 19, 10)
render_qr_code(pdf, letter, 5, 55, 50)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,41 +0,0 @@
# frozen_string_literal: true
module SnailMail
module Templates
class HalfLetterTemplate < BaseTemplate
ADDRESS_FONT = "f25"
def self.template_name
raise NotImplementedError, "Subclass must implement template_name"
end
def self.template_size
:half_letter
end
def render_front(pdf, letter)
raise NotImplementedError, "Subclass must implement render_front"
end
def render(pdf, letter)
render_front(pdf, letter)
pdf.start_new_page
render_postage(pdf, letter)
render_return_address(pdf, letter, 10, pdf.bounds.top - 10, 146, 70)
render_imb(pdf, letter, pdf.bounds.right - 200, pdf.bounds.bottom + 17, 180)
render_destination_address(
pdf,
letter,
150,
pdf.bounds.bottom + 210,
300,
100,
{ size: 23, valign: :bottom, align: :left, font: self.class::ADDRESS_FONT }
)
end
end
end
end

View file

@ -1,46 +0,0 @@
module SnailMail
module Templates
class HCBStickersTemplate < BaseTemplate
def self.template_name
"HCB Stickers"
end
def render(pdf, letter)
pdf.image(
image_path("lilia-hcb-stickers-bg.png"),
at: [0, 288],
width: 432,
)
# Render speech bubble
# pdf.image(
# image_path(speech_bubble_image),
# at: [speech_position[:x], speech_position[:y]],
# width: speech_position[:width]
# )
# Render return address
render_return_address(pdf, letter, 10, 278, 146, 70)
# Render destination address in speech bubble
render_destination_address(
pdf,
letter,
192,
149,
226,
57,
{ size: 16, valign: :bottom, align: :left }
)
# Render IMb barcode
render_imb(pdf, letter, 216, 25, 207)
render_letter_id(pdf, letter, 10, 12, 10)
render_qr_code(pdf, letter, 5, 196, 50)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,35 +0,0 @@
module SnailMail
module Templates
class HCBWelcomePostcardTemplate < HalfLetterTemplate
ADDRESS_FONT = "arial"
def self.template_name
"hcb welcome postcard"
end
SAMPLE_WELCOME_TEXT = "Hey!
I'm super excited to work with your org because I think whatever you do is a really important cause and it aligns perfectly with our mission to support things that we believe are good.
At HCB, we're all about empowering organizations like yours to make a real difference in the world. We believe in the power of community, innovation, and collaboration to create positive change. Your work resonates deeply with our values, and we can't wait to see the amazing things we'll accomplish together.
We're here to support you every step of the way. Whether you need technical assistance, community resources, or just someone to bounce ideas off of, our team is ready to help. We're not just a service provider we're your partner in making the world a better place.
Let's build something incredible together!
Warm regards,
The HCB Team"
def render_front(pdf, letter)
pdf.bounding_box([10, pdf.bounds.top - 10], width: pdf.bounds.width - 20, height: pdf.bounds.height - 20) do
pdf.image(image_path("hcb/hcb-icon.png"), width: 60)
pdf.text_box("Welcome to HCB!", size: 30, at: [70, pdf.bounds.top - 18])
end
pdf.bounding_box([20, pdf.bounds.top - 90], width: pdf.bounds.width - 40, height: pdf.bounds.height - 100) do
pdf.text(letter.rubber_stamps || "", size: 15, align: :justify, overflow: :shrink_to_fit)
end
end
end
end
end

View file

@ -1,55 +0,0 @@
module SnailMail
module Templates
class HcpcxcTemplate < BaseTemplate
def self.template_name
"hcpcxc"
end
def render(pdf, letter)
pdf.image(
image_path("dino-waving.png"),
at: [ 333, 163 ],
width: 87
)
pdf.image(
image_path("hcpcxc_ra.png"),
at: [ 5, 288-5 ],
width: 175
)
render_destination_address(
pdf,
letter,
88,
166,
236,
71,
{ size: 16, valign: :bottom, align: :left }
)
# Render IMb barcode
render_imb(pdf, letter, 240, 24, 183)
render_qr_code(pdf, letter, 5, 65, 60)
render_letter_id(pdf, letter, 10, 19, 10)
if letter.rubber_stamps.present?
pdf.font("arial") do
pdf.text_box(
letter.rubber_stamps,
at: [ 294, 220 ],
width: 255,
height: 21,
overflow: :shrink_to_fit,
disable_wrap_by_char: true,
min_size: 1
)
end
end
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,49 +0,0 @@
module SnailMail
module Templates
class HeidiReadmeTemplate < BaseTemplate
def self.template_name
"Heidi Can't Readme"
end
def self.show_on_single?
true
end
def render(pdf, letter)
render_return_address(pdf, letter, 10, 278, 190, 90, size: 12, font: "f25")
render_destination_address(
pdf,
letter,
133,
176,
256,
107,
{ size: 18, valign: :center, align: :left }
)
pdf.stroke do
pdf.line_width = 2.5
pdf.rounded_rectangle([90 + 20, 189 - 5], 306, 122, 10)
end
pdf.image(
image_path("msw-heidi-cant-readme.png"),
at: [6 + 20, 75],
width: 111,
)
pdf.image(
image_path("speech-tail.png"),
at: [114 + 20, 70 - 5],
width: 32.2,
)
render_imb(pdf, letter, 230, 25, 190)
render_letter_id(pdf, letter, 3, 15, 8, rotate: 90)
render_qr_code(pdf, letter, 7, 72 + 7 + 50 + 10 + 12, 60)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,43 +0,0 @@
module SnailMail
module Templates
class JoyousCatTemplate < BaseTemplate
def self.template_name
"Joyous Cat :3"
end
def self.show_on_single?
true
end
def render(pdf, letter)
pdf.line_width = 3
pdf.stroke do
pdf.rounded_rectangle([111, 189], 306, 122, 10)
end
pdf.image(
image_path("acon-joyous-cat.png"),
at: [208, 74],
width: 106.4,
)
render_return_address(pdf, letter, 10, 270, 130, 70)
render_destination_address(
pdf,
letter,
134,
173,
266,
67,
{ size: 16, valign: :center, align: :left }
)
render_imb(pdf, letter, 131, 100, 266)
render_qr_code(pdf, letter, 7, 72 + 7, 72)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,39 +0,0 @@
module SnailMail
module Templates
class KestrelHeidiTemplate < BaseTemplate
def self.template_name
"kestrel's heidi template!"
end
def self.show_on_single?
true
end
def render(pdf, letter)
pdf.image(
image_path("kestrel-mail-heidi.png"),
at: [107, 216],
width: 305,
)
render_return_address(pdf, letter, 10, 278, 190, 90, size: 14)
render_destination_address(
pdf,
letter,
126,
201,
266,
67,
{ size: 16, valign: :center, align: :left }
)
render_imb(pdf, letter, 124, 120, 200)
render_qr_code(pdf, letter, 7, 72 + 7, 72)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,49 +0,0 @@
module SnailMail
module Templates
class MailOrpheusTemplate < BaseTemplate
def self.template_name
"Mail Orpheus!"
end
def self.show_on_single?
true
end
def render(pdf, letter)
pdf.image(
image_path("eleeza-mail-orpheus.png"),
at: [320, 113],
width: 106.4,
)
# Render speech bubble
# pdf.image(
# image_path(speech_bubble_image),
# at: [speech_position[:x], speech_position[:y]],
# width: speech_position[:width]
# )
# Render return address
render_return_address(pdf, letter, 10, 270, 130, 70)
# Render destination address in speech bubble
render_destination_address(
pdf,
letter,
79.5,
202,
237,
100,
{ size: 16, valign: :bottom, align: :left }
)
# Render IMb barcode
render_imb(pdf, letter, 78, 102, 237)
# Render QR code for tracking
render_qr_code(pdf, letter, 7, 67, 60)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,15 +0,0 @@
require_relative "character_template"
module SnailMail
module Templates
class OrpheusTemplate < CharacterTemplate
def initialize(options = {})
super(options.merge(
character_image: "eleeza-mail-orpheus.png",
character_position: { x: 10, y: 85, width: 130 },
speech_position: { x: 95, y: 240, width: 290, height: 90 }
))
end
end
end
end

View file

@ -1,38 +0,0 @@
module SnailMail
module Templates
class SummerOfMakingFreeStickersTemplate < BaseTemplate
def self.template_name
"SoM Free Stickers"
end
def self.show_on_single?
true
end
def render(pdf, letter)
render_return_address(pdf, letter, 5, pdf.bounds.top - 5, 190, 90, size: 8, font: "f25")
render_destination_address(
pdf,
letter,
120,
115,
270,
81,
{ size: 18, valign: :center, align: :left }
)
pdf.image(
image_path("som/banner.png"),
at: [-5, 288 - 56],
width: 445,
)
render_imb(pdf, letter, 245, 20, 170)
render_letter_id(pdf, letter, 3, 15, 8, rotate: 90)
render_qr_code(pdf, letter, 2, 52, 50)
render_postage(pdf, letter)
end
end
end
end

View file

@ -1,63 +0,0 @@
module SnailMail
module Templates
class TarotTemplate < BaseTemplate
def self.template_name
"Tarot"
end
def render(pdf, letter)
render_return_address(pdf, letter, 10, 278, 190, 90, size: 12, font: 'comic')
if letter.rubber_stamps.present?
pdf.font("gohu") do
pdf.text_box(
"\"#{letter.rubber_stamps}\"",
at: [ 137, 183 ],
width: 255,
height: 21,
overflow: :shrink_to_fit,
disable_wrap_by_char: true,
min_size: 1
)
end
end
render_destination_address(
pdf,
letter,
137,
160,
255,
90,
{ size: 16, valign: :center, align: :left }
)
pdf.stroke do
pdf.line_width = 1
pdf.line([ 137 - 25, 167 ], [ 392 + 25, 167 ])
end
pdf.stroke do
pdf.line_width = 2.5
pdf.rounded_rectangle([ 111, 189 ], 306, 122, 10)
end
pdf.image(
image_path("tarot/msw-joker.png"),
at: [ 6, 104 ],
width: 111
)
pdf.image(
image_path("speech-tail.png"),
at: [ 118, 70 ],
width: 32.2
)
render_imb(pdf, letter, 216, 25, 207)
render_letter_id(pdf, letter, 3, 15, 8, rotate: 90)
render_qr_code(pdf, letter, 7, 72 + 7, 72)
render_postage(pdf, letter)
end
end
end
end

View file

@ -116,7 +116,7 @@ class Letter < ApplicationRecord
# Generate a label for this letter
def generate_label(options = {})
pdf = SnailMail::Service.generate_label(self, options)
pdf = SnailMail::PhlexService.generate_label(self, options)
# Directly attach the PDF to this letter
attach_pdf(pdf.render)

View file

@ -348,7 +348,7 @@ class Letter::Batch < Batch
end
# Use the SnailMail service to generate labels
pdf = SnailMail::Service.generate_batch_labels(
pdf = SnailMail::PhlexService.generate_batch_labels(
preloaded_letters,
label_options.merge(options)
)

View file

@ -13,7 +13,7 @@
<%= form.label :template_cycle, "Label Templates" %>
<div class="help-text">Select multiple templates to cycle through them, or just one for all labels.</div>
<select name="batch[template_cycle][]" id="batch_template_cycle" multiple="multiple" size="8" class="template-select">
<% standard_templates = SnailMail::Service.templates_for_size(:standard) %>
<% standard_templates = SnailMail::PhlexService.templates_for_size(:standard) %>
<% if standard_templates.present? %>
<optgroup label="Standard 4x6 Labels">
<% standard_templates.uniq.each do |template| %>
@ -22,7 +22,7 @@
</optgroup>
<% end %>
<% envelope_templates = SnailMail::Service.templates_for_size(:envelope) %>
<% envelope_templates = SnailMail::PhlexService.templates_for_size(:envelope) %>
<% if envelope_templates.present? %>
<optgroup label="#10 Envelopes">
<% envelope_templates.uniq.each do |template| %>

View file

@ -11,9 +11,9 @@
<% end %>
</div>
<div class="template-picker__grid">
<% templates = (local_assigns[:multiple] || local_assigns[:show_all]) ? SnailMail::Templates.available_templates : SnailMail::Templates.available_single_templates %>
<% templates = (local_assigns[:multiple] || local_assigns[:show_all]) ? SnailMail::Components::Registry.available_templates : SnailMail::Components::Registry.available_single_templates %>
<% templates.each do |template_name| %>
<% template_class = SnailMail::Templates.get_template_class(template_name) %>
<% template_class = SnailMail::Components::Registry.get_component_class(template_name) %>
<% preview_image = "images/template_previews/#{template_class.name.split('::').last.underscore}.png" %>
<div class="template-picker__item"
data-template-name="<%= template_name %>">

View file

@ -1,4 +1,9 @@
# Initializer to load SnailMail templates
Rails.application.config.after_initialize do
SnailMail::Templates.available_templates
Rails.application.config.to_prepare do
# Eager load templates for descendants lookup
templates_dir = Rails.root.join("app", "lib", "snail_mail", "components", "templates")
Rails.autoloaders.main.eager_load_dir(templates_dir)
# Verify templates are loaded
SnailMail::Components::Registry.available_templates
end