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
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 05: Custom Components
|
|
5
|
+
# Run: ruby examples/05_custom_components.rb
|
|
6
|
+
#
|
|
7
|
+
# Demonstrates:
|
|
8
|
+
# - Creating custom components
|
|
9
|
+
# - Registering components with Pdf.register_component
|
|
10
|
+
# - Using custom components in DSL
|
|
11
|
+
# - Complex component rendering with Prawn primitives
|
|
12
|
+
# - Components with multiple render styles
|
|
13
|
+
# - Building a component library
|
|
14
|
+
|
|
15
|
+
require_relative "../lib/pdf"
|
|
16
|
+
|
|
17
|
+
# =============================================================================
|
|
18
|
+
# CUSTOM COMPONENTS
|
|
19
|
+
# =============================================================================
|
|
20
|
+
|
|
21
|
+
# --- Badge Component ---
|
|
22
|
+
# Renders a colored badge/pill with text
|
|
23
|
+
|
|
24
|
+
class Badge < Pdf::Component
|
|
25
|
+
STYLES = {
|
|
26
|
+
"success" => { bg: "4CAF50", text: "FFFFFF" },
|
|
27
|
+
"warning" => { bg: "FF9800", text: "000000" },
|
|
28
|
+
"danger" => { bg: "F44336", text: "FFFFFF" },
|
|
29
|
+
"info" => { bg: "2196F3", text: "FFFFFF" },
|
|
30
|
+
"default" => { bg: "9E9E9E", text: "FFFFFF" }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def render(label, style: "default", **_options)
|
|
34
|
+
colors = STYLES[style.to_s] || STYLES["default"]
|
|
35
|
+
width = @pdf.width_of(label, size: 9) + 16
|
|
36
|
+
height = 18
|
|
37
|
+
|
|
38
|
+
@pdf.bounding_box([0, cursor], width: width, height: height) do
|
|
39
|
+
@pdf.fill_color colors[:bg]
|
|
40
|
+
@pdf.fill_rounded_rectangle [0, height], width, height, 4
|
|
41
|
+
|
|
42
|
+
@pdf.fill_color colors[:text]
|
|
43
|
+
@pdf.text_box label.to_s.upcase,
|
|
44
|
+
at: [8, height - 4],
|
|
45
|
+
width: width - 16,
|
|
46
|
+
height: height - 4,
|
|
47
|
+
size: 9,
|
|
48
|
+
style: :bold,
|
|
49
|
+
valign: :center
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@pdf.fill_color "000000"
|
|
53
|
+
move_down height + 5
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- Progress Bar Component ---
|
|
58
|
+
# Renders a progress bar with percentage
|
|
59
|
+
|
|
60
|
+
class ProgressBar < Pdf::Component
|
|
61
|
+
COLORS = {
|
|
62
|
+
"green" => "4CAF50",
|
|
63
|
+
"blue" => "2196F3",
|
|
64
|
+
"orange" => "FF9800",
|
|
65
|
+
"red" => "F44336"
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
def render(percent, label: nil, color: "blue", width: nil, **_options)
|
|
69
|
+
bar_width = width || bounds.width
|
|
70
|
+
bar_height = 20
|
|
71
|
+
percent = [[percent.to_f, 0].max, 100].min # Clamp 0-100
|
|
72
|
+
|
|
73
|
+
# Label
|
|
74
|
+
if label
|
|
75
|
+
@pdf.text label, size: 10, style: :bold
|
|
76
|
+
move_down 5
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@pdf.bounding_box([0, cursor], width: bar_width, height: bar_height) do
|
|
80
|
+
# Background
|
|
81
|
+
@pdf.fill_color "E0E0E0"
|
|
82
|
+
@pdf.fill_rounded_rectangle [0, bar_height], bar_width, bar_height, 4
|
|
83
|
+
|
|
84
|
+
# Progress fill
|
|
85
|
+
if percent > 0
|
|
86
|
+
fill_width = (bar_width * percent / 100.0).round
|
|
87
|
+
@pdf.fill_color COLORS[color.to_s] || COLORS["blue"]
|
|
88
|
+
@pdf.fill_rounded_rectangle [0, bar_height], fill_width, bar_height, 4
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Percentage text
|
|
92
|
+
@pdf.fill_color "333333"
|
|
93
|
+
@pdf.text_box "#{percent.round}%",
|
|
94
|
+
at: [0, bar_height - 3],
|
|
95
|
+
width: bar_width,
|
|
96
|
+
height: bar_height,
|
|
97
|
+
size: 10,
|
|
98
|
+
style: :bold,
|
|
99
|
+
align: :center
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@pdf.fill_color "000000"
|
|
103
|
+
move_down bar_height + 10
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Stat Card Component ---
|
|
108
|
+
# Renders a highlighted statistic with label and optional delta
|
|
109
|
+
|
|
110
|
+
class StatCard < Pdf::Component
|
|
111
|
+
def render(value:, label:, delta: nil, delta_color: nil, **_options)
|
|
112
|
+
card_width = 150
|
|
113
|
+
card_height = 70
|
|
114
|
+
|
|
115
|
+
@pdf.bounding_box([0, cursor], width: card_width, height: card_height) do
|
|
116
|
+
# Card background
|
|
117
|
+
@pdf.fill_color "F5F5F5"
|
|
118
|
+
@pdf.stroke_color "E0E0E0"
|
|
119
|
+
@pdf.line_width 1
|
|
120
|
+
@pdf.fill_and_stroke_rounded_rectangle [0, card_height], card_width, card_height, 6
|
|
121
|
+
|
|
122
|
+
# Value (large)
|
|
123
|
+
@pdf.fill_color "333333"
|
|
124
|
+
@pdf.text_box value.to_s,
|
|
125
|
+
at: [10, card_height - 10],
|
|
126
|
+
width: card_width - 20,
|
|
127
|
+
size: 22,
|
|
128
|
+
style: :bold
|
|
129
|
+
|
|
130
|
+
# Label
|
|
131
|
+
@pdf.fill_color "666666"
|
|
132
|
+
@pdf.text_box label.to_s,
|
|
133
|
+
at: [10, card_height - 38],
|
|
134
|
+
width: card_width - 20,
|
|
135
|
+
size: 10
|
|
136
|
+
|
|
137
|
+
# Delta (if provided)
|
|
138
|
+
if delta
|
|
139
|
+
color = delta_color || (delta.to_s.start_with?("+") ? "4CAF50" : delta.to_s.start_with?("-") ? "F44336" : "666666")
|
|
140
|
+
@pdf.fill_color color
|
|
141
|
+
@pdf.text_box delta.to_s,
|
|
142
|
+
at: [10, card_height - 52],
|
|
143
|
+
width: card_width - 20,
|
|
144
|
+
size: 9
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
@pdf.fill_color "000000"
|
|
149
|
+
@pdf.stroke_color "000000"
|
|
150
|
+
move_down card_height + 10
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- Divider Component ---
|
|
155
|
+
# Renders a styled divider line
|
|
156
|
+
|
|
157
|
+
class Divider < Pdf::Component
|
|
158
|
+
STYLES = {
|
|
159
|
+
"solid" => [],
|
|
160
|
+
"dashed" => [4, 2],
|
|
161
|
+
"dotted" => [1, 2]
|
|
162
|
+
}.freeze
|
|
163
|
+
|
|
164
|
+
def render(style: "solid", color: "CCCCCC", thickness: 1, spacing: 15, **_options)
|
|
165
|
+
move_down spacing / 2
|
|
166
|
+
|
|
167
|
+
@pdf.stroke_color color
|
|
168
|
+
@pdf.line_width thickness
|
|
169
|
+
dash_pattern = STYLES[style.to_s]
|
|
170
|
+
@pdf.dash(dash_pattern) unless dash_pattern.nil? || dash_pattern.empty?
|
|
171
|
+
@pdf.stroke_horizontal_line 0, bounds.width
|
|
172
|
+
@pdf.undash
|
|
173
|
+
|
|
174
|
+
@pdf.stroke_color "000000"
|
|
175
|
+
@pdf.line_width 1
|
|
176
|
+
move_down spacing / 2
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# --- Callout Component ---
|
|
181
|
+
# Renders a callout box with icon indicator
|
|
182
|
+
|
|
183
|
+
class Callout < Pdf::Component
|
|
184
|
+
TYPES = {
|
|
185
|
+
"tip" => { bg: "E8F5E9", border: "4CAF50", icon: "TIP" },
|
|
186
|
+
"note" => { bg: "E3F2FD", border: "2196F3", icon: "NOTE" },
|
|
187
|
+
"warning" => { bg: "FFF3E0", border: "FF9800", icon: "WARN" },
|
|
188
|
+
"danger" => { bg: "FFEBEE", border: "F44336", icon: "ALERT" }
|
|
189
|
+
}.freeze
|
|
190
|
+
|
|
191
|
+
def render(text, type: "note", **_options)
|
|
192
|
+
config = TYPES[type.to_s] || TYPES["note"]
|
|
193
|
+
padding = 12
|
|
194
|
+
icon_width = 50
|
|
195
|
+
|
|
196
|
+
text_height = @pdf.height_of(text.to_s, width: bounds.width - icon_width - padding * 3, size: 10)
|
|
197
|
+
box_height = [text_height + padding * 2, 50].max
|
|
198
|
+
|
|
199
|
+
@pdf.bounding_box([0, cursor], width: bounds.width, height: box_height) do
|
|
200
|
+
# Background
|
|
201
|
+
@pdf.fill_color config[:bg]
|
|
202
|
+
@pdf.stroke_color config[:border]
|
|
203
|
+
@pdf.line_width 2
|
|
204
|
+
@pdf.fill_and_stroke_rounded_rectangle [0, box_height], bounds.width, box_height, 6
|
|
205
|
+
|
|
206
|
+
# Icon area
|
|
207
|
+
@pdf.fill_color config[:border]
|
|
208
|
+
@pdf.text_box config[:icon],
|
|
209
|
+
at: [padding, box_height - padding],
|
|
210
|
+
width: icon_width - padding,
|
|
211
|
+
size: 10,
|
|
212
|
+
style: :bold
|
|
213
|
+
|
|
214
|
+
# Text
|
|
215
|
+
@pdf.fill_color "333333"
|
|
216
|
+
@pdf.text_box text.to_s,
|
|
217
|
+
at: [icon_width, box_height - padding],
|
|
218
|
+
width: bounds.width - icon_width - padding * 2,
|
|
219
|
+
size: 10,
|
|
220
|
+
leading: 3
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
@pdf.fill_color "000000"
|
|
224
|
+
@pdf.stroke_color "000000"
|
|
225
|
+
@pdf.line_width 1
|
|
226
|
+
move_down box_height + 10
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# --- Two Column Layout Component ---
|
|
231
|
+
# Renders content in two columns
|
|
232
|
+
|
|
233
|
+
class TwoColumn < Pdf::Component
|
|
234
|
+
def render(left:, right:, ratio: 0.5, gap: 20, **_options)
|
|
235
|
+
left_width = (bounds.width * ratio) - (gap / 2)
|
|
236
|
+
right_width = bounds.width - left_width - gap
|
|
237
|
+
|
|
238
|
+
# Calculate heights
|
|
239
|
+
left_height = calculate_content_height(left, left_width)
|
|
240
|
+
right_height = calculate_content_height(right, right_width)
|
|
241
|
+
row_height = [left_height, right_height].max
|
|
242
|
+
|
|
243
|
+
start_cursor = cursor
|
|
244
|
+
|
|
245
|
+
# Left column
|
|
246
|
+
@pdf.bounding_box([0, start_cursor], width: left_width, height: row_height) do
|
|
247
|
+
render_content(left)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Right column
|
|
251
|
+
@pdf.bounding_box([left_width + gap, start_cursor], width: right_width, height: row_height) do
|
|
252
|
+
render_content(right)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Move cursor below both columns
|
|
256
|
+
@pdf.move_cursor_to(start_cursor - row_height)
|
|
257
|
+
move_down 10
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def calculate_content_height(content, width)
|
|
263
|
+
case content
|
|
264
|
+
when String
|
|
265
|
+
@pdf.height_of(content, width: width, size: 10) + 10
|
|
266
|
+
when Hash
|
|
267
|
+
60 # Approximate for stat cards, etc.
|
|
268
|
+
else
|
|
269
|
+
50
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def render_content(content)
|
|
274
|
+
case content
|
|
275
|
+
when String
|
|
276
|
+
@pdf.text content, size: 10, leading: 3
|
|
277
|
+
when Hash
|
|
278
|
+
if content[:stat]
|
|
279
|
+
@pdf.text content[:stat][:value].to_s, size: 20, style: :bold
|
|
280
|
+
@pdf.text content[:stat][:label].to_s, size: 10, color: "666666"
|
|
281
|
+
else
|
|
282
|
+
@pdf.text content.to_s, size: 10
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# =============================================================================
|
|
289
|
+
# REGISTER ALL CUSTOM COMPONENTS
|
|
290
|
+
# =============================================================================
|
|
291
|
+
|
|
292
|
+
Pdf.register_component(:badge, Badge)
|
|
293
|
+
Pdf.register_component(:progress_bar, ProgressBar)
|
|
294
|
+
Pdf.register_component(:stat_card, StatCard)
|
|
295
|
+
Pdf.register_component(:divider, Divider)
|
|
296
|
+
Pdf.register_component(:callout, Callout)
|
|
297
|
+
Pdf.register_component(:two_column, TwoColumn)
|
|
298
|
+
|
|
299
|
+
# =============================================================================
|
|
300
|
+
# DASHBOARD VIEW USING CUSTOM COMPONENTS
|
|
301
|
+
# =============================================================================
|
|
302
|
+
|
|
303
|
+
class Dashboard < Pdf::View
|
|
304
|
+
title "Project Dashboard", size: 24
|
|
305
|
+
subtitle :project_name
|
|
306
|
+
date :report_date
|
|
307
|
+
|
|
308
|
+
divider style: "solid", color: "333333", thickness: 2
|
|
309
|
+
|
|
310
|
+
# Status badges row
|
|
311
|
+
heading "Project Status", size: 14
|
|
312
|
+
badge :status_badge, style: :status_style
|
|
313
|
+
spacer amount: 5
|
|
314
|
+
|
|
315
|
+
# Key metrics with stat cards
|
|
316
|
+
heading "Key Metrics", size: 14
|
|
317
|
+
spacer amount: 5
|
|
318
|
+
|
|
319
|
+
each(:metrics) do |metric|
|
|
320
|
+
stat_card value: metric[:value], label: metric[:label], delta: metric[:delta], delta_color: metric[:color]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
divider style: "dashed"
|
|
324
|
+
|
|
325
|
+
# Progress section
|
|
326
|
+
heading "Sprint Progress", size: 14
|
|
327
|
+
|
|
328
|
+
each(:progress_items) do |item|
|
|
329
|
+
progress_bar item[:percent], label: item[:label], color: item[:color]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
divider style: "dotted"
|
|
333
|
+
|
|
334
|
+
# Callouts
|
|
335
|
+
heading "Notes & Alerts", size: 14
|
|
336
|
+
|
|
337
|
+
each(:callouts) do |callout|
|
|
338
|
+
callout callout[:text], type: callout[:type]
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
divider
|
|
342
|
+
|
|
343
|
+
# Summary in two columns
|
|
344
|
+
heading "Quick Summary", size: 14
|
|
345
|
+
|
|
346
|
+
two_column left: :summary_left, right: :summary_right, ratio: 0.6
|
|
347
|
+
|
|
348
|
+
spacer amount: 20
|
|
349
|
+
span "Dashboard generated automatically. Data refreshed hourly.", size: 8, color: "888888"
|
|
350
|
+
|
|
351
|
+
# --- Data Methods ---
|
|
352
|
+
|
|
353
|
+
def project_name
|
|
354
|
+
"#{data[:project]} - Sprint #{data[:sprint]}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def report_date
|
|
358
|
+
Time.now
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def status_badge
|
|
362
|
+
data[:status]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def status_style
|
|
366
|
+
{ "On Track" => "success", "At Risk" => "warning", "Behind" => "danger", "Completed" => "info" }[data[:status]] || "default"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def metrics
|
|
370
|
+
data[:metrics] || []
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def progress_items
|
|
374
|
+
data[:progress] || []
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def callouts
|
|
378
|
+
data[:callouts] || []
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def summary_left
|
|
382
|
+
<<~TEXT
|
|
383
|
+
Team velocity has increased by 15% this sprint.
|
|
384
|
+
All critical path items are on track for delivery.
|
|
385
|
+
Two blockers were resolved this week.
|
|
386
|
+
TEXT
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def summary_right
|
|
390
|
+
<<~TEXT
|
|
391
|
+
Next milestone: #{data[:next_milestone]}
|
|
392
|
+
Days remaining: #{data[:days_remaining]}
|
|
393
|
+
Team capacity: #{data[:capacity]}%
|
|
394
|
+
TEXT
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# =============================================================================
|
|
399
|
+
# GENERATE THE DASHBOARD
|
|
400
|
+
# =============================================================================
|
|
401
|
+
|
|
402
|
+
dashboard_data = {
|
|
403
|
+
project: "Phoenix Platform",
|
|
404
|
+
sprint: 14,
|
|
405
|
+
status: "On Track",
|
|
406
|
+
|
|
407
|
+
metrics: [
|
|
408
|
+
{ value: "87%", label: "Sprint Completion", delta: "+12%", color: "4CAF50" },
|
|
409
|
+
{ value: "23", label: "Stories Completed", delta: "+5", color: "4CAF50" },
|
|
410
|
+
{ value: "4", label: "Open Blockers", delta: "-2", color: "4CAF50" },
|
|
411
|
+
{ value: "98.5%", label: "Code Coverage", delta: "+0.3%", color: "4CAF50" }
|
|
412
|
+
],
|
|
413
|
+
|
|
414
|
+
progress: [
|
|
415
|
+
{ label: "Backend API", percent: 95, color: "green" },
|
|
416
|
+
{ label: "Frontend UI", percent: 78, color: "blue" },
|
|
417
|
+
{ label: "Testing", percent: 65, color: "orange" },
|
|
418
|
+
{ label: "Documentation", percent: 45, color: "orange" },
|
|
419
|
+
{ label: "Deployment", percent: 20, color: "blue" }
|
|
420
|
+
],
|
|
421
|
+
|
|
422
|
+
callouts: [
|
|
423
|
+
{ type: "tip", text: "Consider pairing on the documentation tasks to accelerate completion." },
|
|
424
|
+
{ type: "note", text: "Sprint review scheduled for Friday at 2pm. Demo environment ready." },
|
|
425
|
+
{ type: "warning", text: "Frontend performance tests showing 200ms regression on dashboard load." },
|
|
426
|
+
{ type: "danger", text: "Security audit findings require immediate attention - 2 critical items." }
|
|
427
|
+
],
|
|
428
|
+
|
|
429
|
+
next_milestone: "Beta Release",
|
|
430
|
+
days_remaining: 12,
|
|
431
|
+
capacity: 85
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# Generate and open
|
|
435
|
+
output_path = "/tmp/dashboard_custom_components.pdf"
|
|
436
|
+
Dashboard.new(dashboard_data).to_file(output_path)
|
|
437
|
+
|
|
438
|
+
puts "Generated: #{output_path}"
|
|
439
|
+
puts "\nRegistered custom components:"
|
|
440
|
+
puts Pdf.components.select { |c| %i[badge progress_bar stat_card divider callout two_column].include?(c) }.map { |c| " - #{c}" }.join("\n")
|
|
441
|
+
|
|
442
|
+
system("open", output_path)
|
data/examples/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# PDF Gem Examples
|
|
2
|
+
|
|
3
|
+
Runnable examples demonstrating the PDF gem features.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd /path/to/pdf
|
|
9
|
+
bundle install
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Running Examples
|
|
13
|
+
|
|
14
|
+
Each example generates a PDF and opens it automatically:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
ruby examples/01_basic_invoice.rb
|
|
18
|
+
ruby examples/02_report_with_layout.rb
|
|
19
|
+
ruby examples/03_inherited_views.rb
|
|
20
|
+
ruby examples/04_conditional_content.rb
|
|
21
|
+
ruby examples/05_custom_components.rb
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Example Overview
|
|
25
|
+
|
|
26
|
+
### 01. Basic Invoice
|
|
27
|
+
**File:** `01_basic_invoice.rb`
|
|
28
|
+
|
|
29
|
+
Demonstrates:
|
|
30
|
+
- Basic view structure
|
|
31
|
+
- Title, subtitle, date components
|
|
32
|
+
- Table with custom columns and widths
|
|
33
|
+
- Sections with nested content
|
|
34
|
+
- Data binding via symbols
|
|
35
|
+
- Computed values from methods
|
|
36
|
+
- Currency formatting
|
|
37
|
+
|
|
38
|
+
**Output:** `/tmp/invoice_example.pdf`
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
### 02. Report with Layout
|
|
43
|
+
**File:** `02_report_with_layout.rb`
|
|
44
|
+
|
|
45
|
+
Demonstrates:
|
|
46
|
+
- Custom layout with header and footer
|
|
47
|
+
- QR code generation from dynamic data
|
|
48
|
+
- Context lines in header
|
|
49
|
+
- Page numbers in footer
|
|
50
|
+
- Multi-section complex report
|
|
51
|
+
- Alerts with different colors
|
|
52
|
+
- Raw Prawn access for custom bar chart
|
|
53
|
+
- Page breaks
|
|
54
|
+
- Tables with data transformation
|
|
55
|
+
|
|
56
|
+
**Output:** `/tmp/quarterly_report_example.pdf`
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### 03. Inherited Views
|
|
61
|
+
**File:** `03_inherited_views.rb`
|
|
62
|
+
|
|
63
|
+
Demonstrates:
|
|
64
|
+
- Base view with shared elements
|
|
65
|
+
- Multi-level inheritance (3 levels deep)
|
|
66
|
+
- Child views extending parents
|
|
67
|
+
- Layout inheritance
|
|
68
|
+
- Method overriding
|
|
69
|
+
- Generating multiple report types from same base
|
|
70
|
+
|
|
71
|
+
**Output:**
|
|
72
|
+
- `/tmp/sales_report_inherited.pdf`
|
|
73
|
+
- `/tmp/hr_report_inherited.pdf`
|
|
74
|
+
- `/tmp/finance_report_inherited.pdf`
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### 04. Conditional Content
|
|
79
|
+
**File:** `04_conditional_content.rb`
|
|
80
|
+
|
|
81
|
+
Demonstrates:
|
|
82
|
+
- `render_if` for conditional sections
|
|
83
|
+
- `render_unless` for inverse conditions
|
|
84
|
+
- Complex boolean logic in conditions
|
|
85
|
+
- Nested conditionals
|
|
86
|
+
- Same view class producing different outputs
|
|
87
|
+
- Dynamic alerts based on state
|
|
88
|
+
|
|
89
|
+
**Output:**
|
|
90
|
+
- `/tmp/order_digital.pdf` (digital-only order)
|
|
91
|
+
- `/tmp/order_gift.pdf` (gift order with extras)
|
|
92
|
+
- `/tmp/order_mixed.pdf` (physical + digital)
|
|
93
|
+
- `/tmp/order_on_hold.pdf` (warning state)
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### 05. Custom Components
|
|
98
|
+
**File:** `05_custom_components.rb`
|
|
99
|
+
|
|
100
|
+
Demonstrates:
|
|
101
|
+
- Creating custom components extending `Pdf::Component`
|
|
102
|
+
- Registering with `Pdf.register_component`
|
|
103
|
+
- Using custom components in DSL
|
|
104
|
+
- Complex rendering with Prawn primitives
|
|
105
|
+
|
|
106
|
+
**Custom Components Created:**
|
|
107
|
+
- `Badge` - Colored status badges
|
|
108
|
+
- `ProgressBar` - Progress indicators
|
|
109
|
+
- `StatCard` - Metric display cards
|
|
110
|
+
- `Divider` - Styled line dividers
|
|
111
|
+
- `Callout` - Tip/note/warning boxes
|
|
112
|
+
- `TwoColumn` - Two-column layout
|
|
113
|
+
|
|
114
|
+
**Output:** `/tmp/dashboard_custom_components.pdf`
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
|
|
120
|
+
- All examples output to `/tmp/` directory
|
|
121
|
+
- Examples use `system("open", path)` to open PDFs (macOS)
|
|
122
|
+
- For Linux, replace with `system("xdg-open", path)`
|
|
123
|
+
- For Windows, replace with `system("start", path)`
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdf
|
|
4
|
+
class Blueprint
|
|
5
|
+
attr_reader :elements, :layout_class, :metadata
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@elements = []
|
|
9
|
+
@layout_class = nil
|
|
10
|
+
@metadata = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(type, *args, **options, &block)
|
|
14
|
+
element = { type: type, args: args, options: options }
|
|
15
|
+
element[:block] = block if block
|
|
16
|
+
@elements << element
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_layout(klass)
|
|
20
|
+
@layout_class = klass
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def set_metadata(key, value)
|
|
24
|
+
@metadata[key] = value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty?
|
|
28
|
+
@elements.empty? && @layout_class.nil? && @metadata.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dup
|
|
32
|
+
copy = Blueprint.new
|
|
33
|
+
copy.instance_variable_set(:@elements, deep_dup_elements)
|
|
34
|
+
copy.instance_variable_set(:@layout_class, @layout_class)
|
|
35
|
+
copy.instance_variable_set(:@metadata, @metadata.dup)
|
|
36
|
+
copy
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def deep_dup_elements
|
|
42
|
+
@elements.map do |el|
|
|
43
|
+
duped = el.dup
|
|
44
|
+
duped[:args] = el[:args].dup
|
|
45
|
+
duped[:options] = el[:options].dup
|
|
46
|
+
duped
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdf
|
|
4
|
+
module Builders
|
|
5
|
+
class ContentBuilder
|
|
6
|
+
include DynamicComponents
|
|
7
|
+
|
|
8
|
+
attr_reader :blueprint
|
|
9
|
+
|
|
10
|
+
def initialize(blueprint = Blueprint.new)
|
|
11
|
+
@blueprint = blueprint
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def title(value, **options)
|
|
15
|
+
@blueprint.add(:title, value, **options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def subtitle(value, **options)
|
|
19
|
+
@blueprint.add(:subtitle, value, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def date(value, **options)
|
|
23
|
+
@blueprint.add(:date, value, **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def heading(value, **options)
|
|
27
|
+
@blueprint.add(:heading, value, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def paragraph(value, **options)
|
|
31
|
+
@blueprint.add(:paragraph, value, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def span(value, **options)
|
|
35
|
+
@blueprint.add(:span, value, **options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hr(**options)
|
|
39
|
+
@blueprint.add(:hr, **options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def spacer(**options)
|
|
43
|
+
@blueprint.add(:spacer, **options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def alert(source = nil, **options)
|
|
47
|
+
@blueprint.add(:alert, source, **options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def table(source, **options)
|
|
51
|
+
@blueprint.add(:table, source, **options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def section(title_text, **options, &block)
|
|
55
|
+
nested = self.class.new
|
|
56
|
+
nested.instance_eval(&block) if block
|
|
57
|
+
@blueprint.add(:section, title_text, nested: nested.blueprint, **options)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def each(source, **options, &block)
|
|
61
|
+
@blueprint.add(:each, source, block: block, **options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def partial(method_name)
|
|
65
|
+
@blueprint.add(:partial, method_name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def page_break
|
|
69
|
+
@blueprint.add(:page_break)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def page_break_if(threshold:)
|
|
73
|
+
@blueprint.add(:page_break_if, threshold: threshold)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Conditional rendering - render block only if condition is truthy
|
|
77
|
+
def render_if(condition, &block)
|
|
78
|
+
nested = self.class.new
|
|
79
|
+
nested.instance_eval(&block) if block
|
|
80
|
+
@blueprint.add(:render_if, condition, nested: nested.blueprint)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Conditional rendering - render block only if condition is falsy
|
|
84
|
+
def render_unless(condition, &block)
|
|
85
|
+
nested = self.class.new
|
|
86
|
+
nested.instance_eval(&block) if block
|
|
87
|
+
@blueprint.add(:render_unless, condition, nested: nested.blueprint)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Escape hatch for direct Prawn access
|
|
91
|
+
def raw(&block)
|
|
92
|
+
@blueprint.add(:raw, block: block)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|