pdf 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/execution/00-overview.md +121 -0
- data/.claude/execution/01-core.md +324 -0
- data/.claude/execution/02-renderer.md +237 -0
- data/.claude/execution/03-components.md +551 -0
- data/.claude/execution/04-builders.md +322 -0
- data/.claude/execution/05-view-layout.md +362 -0
- data/.claude/execution/06-evaluators.md +494 -0
- data/.claude/execution/07-entry-extensibility.md +435 -0
- data/.claude/execution/08-integration-tests.md +978 -0
- data/Rakefile +7 -3
- data/examples/01_basic_invoice.rb +139 -0
- data/examples/02_report_with_layout.rb +266 -0
- data/examples/03_inherited_views.rb +318 -0
- data/examples/04_conditional_content.rb +421 -0
- data/examples/05_custom_components.rb +442 -0
- data/examples/README.md +123 -0
- data/lib/pdf/blueprint.rb +50 -0
- data/lib/pdf/builders/content_builder.rb +96 -0
- data/lib/pdf/builders/footer_builder.rb +24 -0
- data/lib/pdf/builders/header_builder.rb +31 -0
- data/lib/pdf/component.rb +43 -0
- data/lib/pdf/components/alert.rb +42 -0
- data/lib/pdf/components/context.rb +16 -0
- data/lib/pdf/components/date.rb +28 -0
- data/lib/pdf/components/heading.rb +12 -0
- data/lib/pdf/components/hr.rb +12 -0
- data/lib/pdf/components/logo.rb +15 -0
- data/lib/pdf/components/paragraph.rb +12 -0
- data/lib/pdf/components/qr_code.rb +38 -0
- data/lib/pdf/components/spacer.rb +11 -0
- data/lib/pdf/components/span.rb +12 -0
- data/lib/pdf/components/subtitle.rb +12 -0
- data/lib/pdf/components/table.rb +48 -0
- data/lib/pdf/components/title.rb +12 -0
- data/lib/pdf/content_evaluator.rb +218 -0
- data/lib/pdf/dynamic_components.rb +17 -0
- data/lib/pdf/footer_evaluator.rb +66 -0
- data/lib/pdf/header_evaluator.rb +56 -0
- data/lib/pdf/layout.rb +61 -0
- data/lib/pdf/renderer.rb +153 -0
- data/lib/pdf/resolver.rb +36 -0
- data/lib/pdf/version.rb +1 -1
- data/lib/pdf/view.rb +113 -0
- data/lib/pdf.rb +74 -1
- metadata +127 -2
data/Rakefile
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
-
require "
|
|
4
|
+
require "rake/testtask"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
7
11
|
|
|
8
12
|
require "rubocop/rake_task"
|
|
9
13
|
|
|
10
14
|
RuboCop::RakeTask.new
|
|
11
15
|
|
|
12
|
-
task default: %i[
|
|
16
|
+
task default: %i[test rubocop]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 01: Basic Invoice
|
|
5
|
+
# Run: ruby examples/01_basic_invoice.rb
|
|
6
|
+
#
|
|
7
|
+
# Demonstrates:
|
|
8
|
+
# - Basic view structure
|
|
9
|
+
# - Title, subtitle, date components
|
|
10
|
+
# - Table with columns
|
|
11
|
+
# - Sections
|
|
12
|
+
# - Data binding via symbols
|
|
13
|
+
# - Method-based computed values
|
|
14
|
+
|
|
15
|
+
require_relative "../lib/pdf"
|
|
16
|
+
|
|
17
|
+
class Invoice < Pdf::View
|
|
18
|
+
title "INVOICE"
|
|
19
|
+
date :invoice_date, format: "%B %d, %Y"
|
|
20
|
+
|
|
21
|
+
spacer amount: 10
|
|
22
|
+
|
|
23
|
+
paragraph :billing_info
|
|
24
|
+
|
|
25
|
+
hr
|
|
26
|
+
|
|
27
|
+
section "Order Items" do
|
|
28
|
+
table :line_items, columns: [
|
|
29
|
+
{ key: :sku, label: "SKU" },
|
|
30
|
+
{ key: :description, label: "Description" },
|
|
31
|
+
{ key: :quantity, label: "Qty" },
|
|
32
|
+
{ key: :unit_price, label: "Unit Price" },
|
|
33
|
+
{ key: :total, label: "Total" }
|
|
34
|
+
], widths: ["15%", "40%", "10%", "17.5%", "17.5%"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
spacer amount: 15
|
|
38
|
+
|
|
39
|
+
section "Summary" do
|
|
40
|
+
paragraph :subtotal_line
|
|
41
|
+
paragraph :tax_line
|
|
42
|
+
heading :total_line, size: 14
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
spacer amount: 20
|
|
46
|
+
|
|
47
|
+
alert :payment_info
|
|
48
|
+
|
|
49
|
+
hr
|
|
50
|
+
|
|
51
|
+
span :footer_note, size: 8, color: "666666"
|
|
52
|
+
|
|
53
|
+
# Data accessors
|
|
54
|
+
|
|
55
|
+
def invoice_date
|
|
56
|
+
data[:date] || Date.today
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def billing_info
|
|
60
|
+
<<~INFO
|
|
61
|
+
Invoice #: #{data[:invoice_number]}
|
|
62
|
+
Customer: #{data[:customer][:name]}
|
|
63
|
+
Address: #{data[:customer][:address]}
|
|
64
|
+
Email: #{data[:customer][:email]}
|
|
65
|
+
INFO
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def line_items
|
|
69
|
+
data[:items].map do |item|
|
|
70
|
+
item.merge(total: format_currency(item[:quantity] * item[:unit_price_cents]))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def subtotal_line
|
|
75
|
+
"Subtotal: #{format_currency(subtotal_cents)}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def tax_line
|
|
79
|
+
"Tax (#{data[:tax_rate]}%): #{format_currency(tax_cents)}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def total_line
|
|
83
|
+
"TOTAL DUE: #{format_currency(subtotal_cents + tax_cents)}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def payment_info
|
|
87
|
+
{
|
|
88
|
+
title: "Payment Due: #{(data[:date] + 30).strftime('%B %d, %Y')}",
|
|
89
|
+
description: "Please remit payment to: #{data[:payment_details]}",
|
|
90
|
+
color: "blue"
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def footer_note
|
|
95
|
+
"Thank you for your business! Questions? Contact us at billing@example.com"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def subtotal_cents
|
|
101
|
+
data[:items].sum { |i| i[:quantity] * i[:unit_price_cents] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def tax_cents
|
|
105
|
+
(subtotal_cents * data[:tax_rate] / 100.0).round
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def format_currency(cents)
|
|
109
|
+
"$#{'%.2f' % (cents / 100.0)}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- Generate the invoice ---
|
|
114
|
+
|
|
115
|
+
invoice_data = {
|
|
116
|
+
invoice_number: "INV-2024-0042",
|
|
117
|
+
date: Date.new(2024, 12, 15),
|
|
118
|
+
tax_rate: 8.5,
|
|
119
|
+
payment_details: "Acme Corp, Account #12345678",
|
|
120
|
+
customer: {
|
|
121
|
+
name: "John Smith",
|
|
122
|
+
address: "123 Main Street, Anytown, ST 12345",
|
|
123
|
+
email: "john.smith@example.com"
|
|
124
|
+
},
|
|
125
|
+
items: [
|
|
126
|
+
{ sku: "WDG-001", description: "Premium Widget (Blue)", quantity: 5, unit_price_cents: 2499 },
|
|
127
|
+
{ sku: "WDG-002", description: "Deluxe Widget (Red)", quantity: 3, unit_price_cents: 3499 },
|
|
128
|
+
{ sku: "GDG-001", description: "Standard Gadget", quantity: 10, unit_price_cents: 999 },
|
|
129
|
+
{ sku: "ACC-001", description: "Widget Mounting Kit", quantity: 2, unit_price_cents: 1599 },
|
|
130
|
+
{ sku: "SVC-001", description: "Installation Service", quantity: 1, unit_price_cents: 7500 }
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Generate and open
|
|
135
|
+
output_path = "/tmp/invoice_example.pdf"
|
|
136
|
+
Invoice.new(invoice_data).to_file(output_path)
|
|
137
|
+
|
|
138
|
+
puts "Generated: #{output_path}"
|
|
139
|
+
system("open", output_path)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 02: Report with Layout
|
|
5
|
+
# Run: ruby examples/02_report_with_layout.rb
|
|
6
|
+
#
|
|
7
|
+
# Demonstrates:
|
|
8
|
+
# - Custom layout with header and footer
|
|
9
|
+
# - QR code generation from data
|
|
10
|
+
# - Context lines in header
|
|
11
|
+
# - Page numbers
|
|
12
|
+
# - Multi-section report
|
|
13
|
+
# - Alerts with different colors
|
|
14
|
+
# - Tables with data transformation
|
|
15
|
+
# - Raw Prawn access for custom drawing
|
|
16
|
+
|
|
17
|
+
require_relative "../lib/pdf"
|
|
18
|
+
|
|
19
|
+
# --- Layout Definition ---
|
|
20
|
+
|
|
21
|
+
class CompanyReportLayout < Pdf::Layout
|
|
22
|
+
header do
|
|
23
|
+
qr_code data: :document_url, size: 50, position: :right
|
|
24
|
+
context :header_context, label: "REPORT INFO"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
footer do
|
|
28
|
+
text :footer_disclaimer, size: 7
|
|
29
|
+
page_number position: :right, format: "Page %<page>d of %<total>d"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
margins top: 90, bottom: 70, left: 50, right: 50
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- Report View ---
|
|
36
|
+
|
|
37
|
+
class QuarterlyReport < Pdf::View
|
|
38
|
+
layout CompanyReportLayout
|
|
39
|
+
|
|
40
|
+
title :report_title, size: 22
|
|
41
|
+
subtitle :report_period
|
|
42
|
+
date :generated_at, format: "Generated: %B %d, %Y at %H:%M"
|
|
43
|
+
|
|
44
|
+
spacer amount: 15
|
|
45
|
+
|
|
46
|
+
alert :executive_summary
|
|
47
|
+
|
|
48
|
+
hr
|
|
49
|
+
|
|
50
|
+
section "Revenue Breakdown" do
|
|
51
|
+
table :revenue_data, columns: [
|
|
52
|
+
{ key: :category, label: "Category" },
|
|
53
|
+
{ key: :q1, label: "Q1" },
|
|
54
|
+
{ key: :q2, label: "Q2" },
|
|
55
|
+
{ key: :q3, label: "Q3" },
|
|
56
|
+
{ key: :q4, label: "Q4" },
|
|
57
|
+
{ key: :total, label: "Total" }
|
|
58
|
+
], widths: ["25%", "15%", "15%", "15%", "15%", "15%"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
spacer amount: 10
|
|
62
|
+
|
|
63
|
+
# Custom chart placeholder using raw Prawn
|
|
64
|
+
raw do |pdf|
|
|
65
|
+
pdf.text "Revenue Trend (Visual)", size: 12, style: :bold
|
|
66
|
+
pdf.move_down 10
|
|
67
|
+
|
|
68
|
+
# Draw a simple bar chart
|
|
69
|
+
start_x = 50
|
|
70
|
+
bar_width = 60
|
|
71
|
+
max_height = 100
|
|
72
|
+
values = [65, 78, 82, 95]
|
|
73
|
+
labels = ["Q1", "Q2", "Q3", "Q4"]
|
|
74
|
+
colors = ["4CAF50", "2196F3", "FF9800", "9C27B0"]
|
|
75
|
+
|
|
76
|
+
values.each_with_index do |val, i|
|
|
77
|
+
x = start_x + (i * (bar_width + 20))
|
|
78
|
+
height = (val / 100.0) * max_height
|
|
79
|
+
|
|
80
|
+
pdf.fill_color colors[i]
|
|
81
|
+
pdf.fill_rectangle [x, pdf.cursor], bar_width, height
|
|
82
|
+
|
|
83
|
+
pdf.fill_color "333333"
|
|
84
|
+
pdf.draw_text labels[i], at: [x + 20, pdf.cursor - height - 15], size: 10
|
|
85
|
+
pdf.draw_text "#{val}%", at: [x + 18, pdf.cursor - height + 10], size: 8
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
pdf.fill_color "000000"
|
|
89
|
+
pdf.move_down max_height + 30
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
hr
|
|
93
|
+
|
|
94
|
+
section "Key Performance Indicators" do
|
|
95
|
+
each(:kpis) do |kpi|
|
|
96
|
+
alert kpi
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
section "Regional Performance" do
|
|
101
|
+
table :regional_data, columns: [
|
|
102
|
+
{ key: :region, label: "Region" },
|
|
103
|
+
{ key: :revenue, label: "Revenue" },
|
|
104
|
+
{ key: :growth, label: "YoY Growth" },
|
|
105
|
+
{ key: :status, label: "Status" }
|
|
106
|
+
]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
spacer amount: 15
|
|
110
|
+
|
|
111
|
+
section "Notes & Observations" do
|
|
112
|
+
each(:observations) do |obs|
|
|
113
|
+
paragraph obs
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
page_break
|
|
118
|
+
|
|
119
|
+
section "Appendix: Detailed Metrics" do
|
|
120
|
+
table :detailed_metrics, columns: [:metric, :value, :target, :variance]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- Data Methods ---
|
|
124
|
+
|
|
125
|
+
def report_title
|
|
126
|
+
"#{data[:company]} Quarterly Report"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def report_period
|
|
130
|
+
"#{data[:quarter]} #{data[:year]}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def generated_at
|
|
134
|
+
Time.now
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def header_context
|
|
138
|
+
[
|
|
139
|
+
"Company: #{data[:company]}",
|
|
140
|
+
"Period: #{data[:quarter]} #{data[:year]}",
|
|
141
|
+
"Confidentiality: #{data[:confidentiality]}"
|
|
142
|
+
]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def document_url
|
|
146
|
+
"https://reports.example.com/#{data[:report_id]}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def footer_disclaimer
|
|
150
|
+
"#{data[:company]} - Confidential. Distribution restricted to authorized personnel only."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def executive_summary
|
|
154
|
+
{
|
|
155
|
+
title: "Executive Summary",
|
|
156
|
+
description: data[:summary],
|
|
157
|
+
color: "blue"
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def revenue_data
|
|
162
|
+
data[:revenue].map do |row|
|
|
163
|
+
row.merge(total: format_currency(row.values_at(:q1, :q2, :q3, :q4).map { |v| parse_currency(v) }.sum))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def kpis
|
|
168
|
+
data[:kpis].map do |kpi|
|
|
169
|
+
color = case kpi[:status]
|
|
170
|
+
when :good then "green"
|
|
171
|
+
when :warning then "orange"
|
|
172
|
+
when :critical then "red"
|
|
173
|
+
else "blue"
|
|
174
|
+
end
|
|
175
|
+
{
|
|
176
|
+
title: "#{kpi[:name]}: #{kpi[:value]}",
|
|
177
|
+
description: "Target: #{kpi[:target]} | #{kpi[:trend]}",
|
|
178
|
+
color: color
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def regional_data
|
|
184
|
+
data[:regions]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def observations
|
|
188
|
+
data[:observations]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def detailed_metrics
|
|
192
|
+
data[:metrics]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def format_currency(cents)
|
|
198
|
+
"$#{cents.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_currency(str)
|
|
202
|
+
str.to_s.gsub(/[$,]/, "").to_i
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- Generate the report ---
|
|
207
|
+
|
|
208
|
+
report_data = {
|
|
209
|
+
company: "Acme Corporation",
|
|
210
|
+
quarter: "Q4",
|
|
211
|
+
year: 2024,
|
|
212
|
+
report_id: "RPT-2024-Q4-001",
|
|
213
|
+
confidentiality: "Internal Use Only",
|
|
214
|
+
|
|
215
|
+
summary: "Q4 2024 showed strong performance across all regions with 18% YoY revenue growth. " \
|
|
216
|
+
"The APAC region exceeded targets by 25%, while EMEA stabilized after Q3 restructuring. " \
|
|
217
|
+
"Customer acquisition costs decreased by 12% due to improved marketing efficiency.",
|
|
218
|
+
|
|
219
|
+
revenue: [
|
|
220
|
+
{ category: "Product Sales", q1: "$1.2M", q2: "$1.4M", q3: "$1.5M", q4: "$1.8M" },
|
|
221
|
+
{ category: "Services", q1: "$800K", q2: "$850K", q3: "$920K", q4: "$1.1M" },
|
|
222
|
+
{ category: "Subscriptions", q1: "$2.1M", q2: "$2.3M", q3: "$2.5M", q4: "$2.8M" },
|
|
223
|
+
{ category: "Licensing", q1: "$400K", q2: "$420K", q3: "$450K", q4: "$500K" }
|
|
224
|
+
],
|
|
225
|
+
|
|
226
|
+
kpis: [
|
|
227
|
+
{ name: "Revenue Growth", value: "+18%", target: "+15%", trend: "Exceeding target", status: :good },
|
|
228
|
+
{ name: "Customer Retention", value: "94%", target: "95%", trend: "Slightly below", status: :warning },
|
|
229
|
+
{ name: "NPS Score", value: "72", target: "70", trend: "Improving", status: :good },
|
|
230
|
+
{ name: "CAC", value: "$142", target: "$150", trend: "12% reduction", status: :good },
|
|
231
|
+
{ name: "Churn Rate", value: "2.8%", target: "2.5%", trend: "Needs attention", status: :warning }
|
|
232
|
+
],
|
|
233
|
+
|
|
234
|
+
regions: [
|
|
235
|
+
{ region: "North America", revenue: "$3.2M", growth: "+15%", status: "On Track" },
|
|
236
|
+
{ region: "EMEA", revenue: "$1.8M", growth: "+8%", status: "Recovering" },
|
|
237
|
+
{ region: "APAC", revenue: "$1.5M", growth: "+32%", status: "Exceeding" },
|
|
238
|
+
{ region: "LATAM", revenue: "$0.7M", growth: "+22%", status: "Growing" }
|
|
239
|
+
],
|
|
240
|
+
|
|
241
|
+
observations: [
|
|
242
|
+
"Product launch in October drove significant Q4 growth in North America.",
|
|
243
|
+
"APAC expansion strategy showing strong ROI, consider accelerating.",
|
|
244
|
+
"EMEA restructuring complete, expect normalized growth in Q1 2025.",
|
|
245
|
+
"Customer feedback indicates high demand for enterprise features.",
|
|
246
|
+
"Recommend increasing R&D budget allocation for H1 2025."
|
|
247
|
+
],
|
|
248
|
+
|
|
249
|
+
metrics: [
|
|
250
|
+
{ metric: "MRR", value: "$580K", target: "$550K", variance: "+5.5%" },
|
|
251
|
+
{ metric: "ARR", value: "$6.96M", target: "$6.6M", variance: "+5.5%" },
|
|
252
|
+
{ metric: "Active Users", value: "45,200", target: "42,000", variance: "+7.6%" },
|
|
253
|
+
{ metric: "Avg. Deal Size", value: "$12,400", target: "$11,000", variance: "+12.7%" },
|
|
254
|
+
{ metric: "Sales Cycle", value: "34 days", target: "40 days", variance: "-15%" },
|
|
255
|
+
{ metric: "Support Tickets", value: "1,240", target: "1,500", variance: "-17.3%" },
|
|
256
|
+
{ metric: "Resolution Time", value: "4.2 hrs", target: "6 hrs", variance: "-30%" },
|
|
257
|
+
{ metric: "Employee Count", value: "128", target: "125", variance: "+2.4%" }
|
|
258
|
+
]
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Generate and open
|
|
262
|
+
output_path = "/tmp/quarterly_report_example.pdf"
|
|
263
|
+
QuarterlyReport.new(report_data).to_file(output_path)
|
|
264
|
+
|
|
265
|
+
puts "Generated: #{output_path}"
|
|
266
|
+
system("open", output_path)
|