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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/execution/00-overview.md +121 -0
  3. data/.claude/execution/01-core.md +324 -0
  4. data/.claude/execution/02-renderer.md +237 -0
  5. data/.claude/execution/03-components.md +551 -0
  6. data/.claude/execution/04-builders.md +322 -0
  7. data/.claude/execution/05-view-layout.md +362 -0
  8. data/.claude/execution/06-evaluators.md +494 -0
  9. data/.claude/execution/07-entry-extensibility.md +435 -0
  10. data/.claude/execution/08-integration-tests.md +978 -0
  11. data/Rakefile +7 -3
  12. data/examples/01_basic_invoice.rb +139 -0
  13. data/examples/02_report_with_layout.rb +266 -0
  14. data/examples/03_inherited_views.rb +318 -0
  15. data/examples/04_conditional_content.rb +421 -0
  16. data/examples/05_custom_components.rb +442 -0
  17. data/examples/README.md +123 -0
  18. data/lib/pdf/blueprint.rb +50 -0
  19. data/lib/pdf/builders/content_builder.rb +96 -0
  20. data/lib/pdf/builders/footer_builder.rb +24 -0
  21. data/lib/pdf/builders/header_builder.rb +31 -0
  22. data/lib/pdf/component.rb +43 -0
  23. data/lib/pdf/components/alert.rb +42 -0
  24. data/lib/pdf/components/context.rb +16 -0
  25. data/lib/pdf/components/date.rb +28 -0
  26. data/lib/pdf/components/heading.rb +12 -0
  27. data/lib/pdf/components/hr.rb +12 -0
  28. data/lib/pdf/components/logo.rb +15 -0
  29. data/lib/pdf/components/paragraph.rb +12 -0
  30. data/lib/pdf/components/qr_code.rb +38 -0
  31. data/lib/pdf/components/spacer.rb +11 -0
  32. data/lib/pdf/components/span.rb +12 -0
  33. data/lib/pdf/components/subtitle.rb +12 -0
  34. data/lib/pdf/components/table.rb +48 -0
  35. data/lib/pdf/components/title.rb +12 -0
  36. data/lib/pdf/content_evaluator.rb +218 -0
  37. data/lib/pdf/dynamic_components.rb +17 -0
  38. data/lib/pdf/footer_evaluator.rb +66 -0
  39. data/lib/pdf/header_evaluator.rb +56 -0
  40. data/lib/pdf/layout.rb +61 -0
  41. data/lib/pdf/renderer.rb +153 -0
  42. data/lib/pdf/resolver.rb +36 -0
  43. data/lib/pdf/version.rb +1 -1
  44. data/lib/pdf/view.rb +113 -0
  45. data/lib/pdf.rb +74 -1
  46. 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)
@@ -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