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,551 @@
1
+ # Phase 3: Built-in Components
2
+
3
+ **Principles:**
4
+ - NEVER swallow errors - all failures bubble up to client
5
+ - Each component is single-responsibility
6
+ - Components only render, no business logic
7
+
8
+ ## Files
9
+ - `lib/pdf/components/title.rb`
10
+ - `lib/pdf/components/subtitle.rb`
11
+ - `lib/pdf/components/heading.rb`
12
+ - `lib/pdf/components/paragraph.rb`
13
+ - `lib/pdf/components/span.rb`
14
+ - `lib/pdf/components/date.rb`
15
+ - `lib/pdf/components/hr.rb`
16
+ - `lib/pdf/components/spacer.rb`
17
+ - `lib/pdf/components/alert.rb`
18
+ - `lib/pdf/components/table.rb`
19
+ - `lib/pdf/components/logo.rb`
20
+ - `lib/pdf/components/qr_code.rb`
21
+ - `lib/pdf/components/context.rb`
22
+
23
+ Note: PageNumber is handled in FooterEvaluator, not as a component.
24
+
25
+ ---
26
+
27
+ ## 3.1 Title
28
+
29
+ ```ruby
30
+ # lib/pdf/components/title.rb
31
+ # frozen_string_literal: true
32
+
33
+ module Pdf
34
+ module Components
35
+ class Title < Component
36
+ def render(content, size: 20, **options)
37
+ text content, size: size, style: :bold, **options
38
+ move_down 10
39
+ end
40
+ end
41
+ end
42
+ end
43
+ ```
44
+
45
+ ---
46
+
47
+ ## 3.2 Subtitle
48
+
49
+ ```ruby
50
+ # lib/pdf/components/subtitle.rb
51
+ # frozen_string_literal: true
52
+
53
+ module Pdf
54
+ module Components
55
+ class Subtitle < Component
56
+ def render(content, size: 16, **options)
57
+ text content, size: size, **options
58
+ move_down 5
59
+ end
60
+ end
61
+ end
62
+ end
63
+ ```
64
+
65
+ ---
66
+
67
+ ## 3.3 Heading
68
+
69
+ ```ruby
70
+ # lib/pdf/components/heading.rb
71
+ # frozen_string_literal: true
72
+
73
+ module Pdf
74
+ module Components
75
+ class Heading < Component
76
+ def render(content, size: 14, **options)
77
+ text content, size: size, style: :bold, **options
78
+ move_down 8
79
+ end
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ ---
86
+
87
+ ## 3.4 Paragraph
88
+
89
+ ```ruby
90
+ # lib/pdf/components/paragraph.rb
91
+ # frozen_string_literal: true
92
+
93
+ module Pdf
94
+ module Components
95
+ class Paragraph < Component
96
+ def render(content, size: 10, leading: 3, **options)
97
+ text content, size: size, leading: leading, **options
98
+ move_down 10
99
+ end
100
+ end
101
+ end
102
+ end
103
+ ```
104
+
105
+ ---
106
+
107
+ ## 3.5 Span
108
+
109
+ ```ruby
110
+ # lib/pdf/components/span.rb
111
+ # frozen_string_literal: true
112
+
113
+ module Pdf
114
+ module Components
115
+ class Span < Component
116
+ def render(content, size: 10, **options)
117
+ text content, size: size, **options
118
+ move_down 3
119
+ end
120
+ end
121
+ end
122
+ end
123
+ ```
124
+
125
+ ---
126
+
127
+ ## 3.6 Date
128
+
129
+ Real Date component for formatted date rendering.
130
+
131
+ ```ruby
132
+ # lib/pdf/components/date.rb
133
+ # frozen_string_literal: true
134
+
135
+ module Pdf
136
+ module Components
137
+ class Date < Component
138
+ DEFAULT_FORMAT = "%B %d, %Y"
139
+
140
+ def render(value, format: DEFAULT_FORMAT, size: 10, align: :right, **options)
141
+ formatted = format_date(value, format)
142
+ text formatted, size: size, align: align, **options
143
+ move_down 5
144
+ end
145
+
146
+ private
147
+
148
+ def format_date(value, format)
149
+ case value
150
+ when ::Date, ::Time, ::DateTime
151
+ value.strftime(format)
152
+ when String
153
+ value
154
+ else
155
+ value.to_s
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ ```
162
+
163
+ ---
164
+
165
+ ## 3.7 Hr
166
+
167
+ ```ruby
168
+ # lib/pdf/components/hr.rb
169
+ # frozen_string_literal: true
170
+
171
+ module Pdf
172
+ module Components
173
+ class Hr < Component
174
+ def render(**_options)
175
+ stroke_horizontal_rule
176
+ move_down 10
177
+ end
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ ---
184
+
185
+ ## 3.8 Spacer
186
+
187
+ Explicit vertical space component.
188
+
189
+ ```ruby
190
+ # lib/pdf/components/spacer.rb
191
+ # frozen_string_literal: true
192
+
193
+ module Pdf
194
+ module Components
195
+ class Spacer < Component
196
+ def render(amount: 20, **_options)
197
+ move_down amount
198
+ end
199
+ end
200
+ end
201
+ end
202
+ ```
203
+
204
+ ---
205
+
206
+ ## 3.9 Alert
207
+
208
+ ```ruby
209
+ # lib/pdf/components/alert.rb
210
+ # frozen_string_literal: true
211
+
212
+ module Pdf
213
+ module Components
214
+ class Alert < Component
215
+ COLORS = {
216
+ "green" => { bg: "C8E6C9", text: "2E7D32", border: "4CAF50" },
217
+ "orange" => { bg: "FFE0B2", text: "E65100", border: "FF9800" },
218
+ "red" => { bg: "FFCDD2", text: "C62828", border: "F44336" },
219
+ "blue" => { bg: "E1F5FE", text: "0277BD", border: "2196F3" }
220
+ }.freeze
221
+
222
+ def render(title:, description: nil, color: "blue", **_options)
223
+ scheme = COLORS[color.to_s] || COLORS["blue"]
224
+
225
+ title_h = 20
226
+ desc_h = description ? (@pdf.height_of(description.to_s, width: bounds.width - 20, size: 10) + 10) : 0
227
+ total_h = title_h + desc_h + 6
228
+
229
+ bounding_box([bounds.left, cursor], width: bounds.width, height: total_h) do
230
+ @pdf.stroke_color scheme[:border]
231
+ @pdf.line_width 0.5
232
+ @pdf.fill_color scheme[:bg]
233
+ @pdf.fill_and_stroke_rounded_rectangle [bounds.left, bounds.top], bounds.width, bounds.height, 5
234
+
235
+ @pdf.fill_color scheme[:text]
236
+
237
+ y = bounds.top - 10
238
+ @pdf.text_box title.to_s, at: [10, y], width: bounds.width - 20, size: 11, style: :bold
239
+
240
+ if description
241
+ y -= 18
242
+ @pdf.text_box description.to_s, at: [10, y], width: bounds.width - 20, size: 10
243
+ end
244
+ end
245
+
246
+ @pdf.fill_color "000000"
247
+ move_down 15
248
+ end
249
+ end
250
+ end
251
+ end
252
+ ```
253
+
254
+ ---
255
+
256
+ ## 3.10 Table
257
+
258
+ ```ruby
259
+ # lib/pdf/components/table.rb
260
+ # frozen_string_literal: true
261
+
262
+ module Pdf
263
+ module Components
264
+ class Table < Component
265
+ def render(headers:, rows:, widths: nil, **_options)
266
+ data = [headers] + rows
267
+
268
+ opts = {
269
+ header: true,
270
+ cell_style: { borders: [:top, :bottom, :left, :right], padding: [4, 6], size: 8 }
271
+ }
272
+
273
+ opts[:column_widths] = calculate_widths(widths) if widths
274
+ opts[:width] = bounds.width unless widths
275
+
276
+ @pdf.table(data, **opts) do
277
+ row(0).background_color = "333333"
278
+ row(0).text_color = "FFFFFF"
279
+ row(0).font_style = :bold
280
+
281
+ (1..row_length - 1).each do |i|
282
+ row(i).background_color = i.odd? ? "F5F5F5" : "FFFFFF"
283
+ end
284
+ end
285
+
286
+ move_down 15
287
+ end
288
+
289
+ private
290
+
291
+ def calculate_widths(widths)
292
+ total = bounds.width
293
+ widths.map do |w|
294
+ case w
295
+ when String then w.end_with?("%") ? (w.to_f / 100 * total).round : w.to_f
296
+ when Numeric then w.to_f
297
+ else raise ArgumentError, "Invalid width: #{w}"
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
304
+ ```
305
+
306
+ ---
307
+
308
+ ## 3.11 Logo
309
+
310
+ No silent failures - raises if file not found.
311
+
312
+ ```ruby
313
+ # lib/pdf/components/logo.rb
314
+ # frozen_string_literal: true
315
+
316
+ module Pdf
317
+ module Components
318
+ class Logo < Component
319
+ def render(path, width: 120, height: nil, **_options)
320
+ raise Pdf::FileNotFoundError, "Logo not found: #{path}" unless File.exist?(path)
321
+
322
+ opts = { width: width }
323
+ opts[:height] = height if height
324
+ @pdf.image path, **opts
325
+ end
326
+ end
327
+ end
328
+ end
329
+ ```
330
+
331
+ ---
332
+
333
+ ## 3.12 QrCode
334
+
335
+ Generates QR code from data using rqrcode gem. No pre-generated image needed.
336
+
337
+ ```ruby
338
+ # lib/pdf/components/qr_code.rb
339
+ # frozen_string_literal: true
340
+
341
+ require "rqrcode"
342
+
343
+ module Pdf
344
+ module Components
345
+ class QrCode < Component
346
+ def render(data:, size: 80, position: :right, **_options)
347
+ png_data = generate_qr_png(data, size)
348
+
349
+ if position == :right
350
+ @pdf.float do
351
+ bounding_box([bounds.width - size, cursor], width: size) do
352
+ @pdf.image StringIO.new(png_data), width: size, height: size
353
+ end
354
+ end
355
+ else
356
+ @pdf.image StringIO.new(png_data), width: size, height: size
357
+ end
358
+ end
359
+
360
+ private
361
+
362
+ def generate_qr_png(data, size)
363
+ qrcode = RQRCode::QRCode.new(data.to_s)
364
+ png = qrcode.as_png(
365
+ bit_depth: 1,
366
+ border_modules: 2,
367
+ color_mode: ChunkyPNG::COLOR_GRAYSCALE,
368
+ color: "black",
369
+ fill: "white",
370
+ size: size * 4 # Higher resolution for quality
371
+ )
372
+ png.to_s
373
+ end
374
+ end
375
+ end
376
+ end
377
+ ```
378
+
379
+ ---
380
+
381
+ ## 3.13 Context
382
+
383
+ ```ruby
384
+ # lib/pdf/components/context.rb
385
+ # frozen_string_literal: true
386
+
387
+ module Pdf
388
+ module Components
389
+ class Context < Component
390
+ def render(lines, label: "CONTEXT", **_options)
391
+ raise ArgumentError, "Context lines cannot be nil" if lines.nil?
392
+ return if lines.empty?
393
+
394
+ text label, size: 12, style: :bold
395
+ lines.each { |line| text line.to_s, size: 10 }
396
+ move_down 20
397
+ end
398
+ end
399
+ end
400
+ end
401
+ ```
402
+
403
+ ---
404
+
405
+ ## Tests (examples)
406
+
407
+ ```ruby
408
+ # test/pdf/components/title_test.rb
409
+ # frozen_string_literal: true
410
+
411
+ require "test_helper"
412
+
413
+ class TitleComponentTest < Minitest::Test
414
+ def setup
415
+ @renderer = Pdf::Renderer.new.setup
416
+ @component = Pdf::Components::Title.new(@renderer.pdf)
417
+ end
418
+
419
+ def test_renders_title_text
420
+ @component.render("Hello")
421
+ # Just verify it doesn't raise - actual rendering tested via integration
422
+ end
423
+ end
424
+
425
+ # test/pdf/components/alert_test.rb
426
+ # frozen_string_literal: true
427
+
428
+ require "test_helper"
429
+
430
+ class AlertComponentTest < Minitest::Test
431
+ def setup
432
+ @renderer = Pdf::Renderer.new.setup
433
+ @component = Pdf::Components::Alert.new(@renderer.pdf)
434
+ end
435
+
436
+ def test_renders_without_error
437
+ @component.render(title: "Test", color: "green")
438
+ end
439
+
440
+ def test_defaults_to_blue
441
+ @component.render(title: "Test")
442
+ end
443
+ end
444
+
445
+ # test/pdf/components/table_test.rb
446
+ # frozen_string_literal: true
447
+
448
+ require "test_helper"
449
+
450
+ class TableComponentTest < Minitest::Test
451
+ def setup
452
+ @renderer = Pdf::Renderer.new.setup
453
+ @component = Pdf::Components::Table.new(@renderer.pdf)
454
+ end
455
+
456
+ def test_renders_table
457
+ @component.render(headers: ["A", "B"], rows: [["1", "2"]])
458
+ end
459
+
460
+ def test_calculates_percentage_widths
461
+ @component.render(headers: ["A", "B"], rows: [["1", "2"]], widths: ["50%", "50%"])
462
+ end
463
+ end
464
+
465
+ # test/pdf/components/date_test.rb
466
+ # frozen_string_literal: true
467
+
468
+ require "test_helper"
469
+
470
+ class DateComponentTest < Minitest::Test
471
+ def setup
472
+ @renderer = Pdf::Renderer.new.setup
473
+ @component = Pdf::Components::Date.new(@renderer.pdf)
474
+ end
475
+
476
+ def test_formats_date_objects
477
+ date = ::Date.new(2024, 12, 25)
478
+ @component.render(date)
479
+ # Actual format verified in integration tests
480
+ end
481
+
482
+ def test_accepts_custom_format
483
+ date = ::Date.new(2024, 12, 25)
484
+ @component.render(date, format: "%d/%m/%Y")
485
+ end
486
+
487
+ def test_passes_through_strings
488
+ @component.render("Custom date")
489
+ end
490
+ end
491
+
492
+ # test/pdf/components/logo_test.rb
493
+ # frozen_string_literal: true
494
+
495
+ require "test_helper"
496
+
497
+ class LogoComponentTest < Minitest::Test
498
+ def setup
499
+ @renderer = Pdf::Renderer.new.setup
500
+ @component = Pdf::Components::Logo.new(@renderer.pdf)
501
+ end
502
+
503
+ def test_raises_file_not_found_error
504
+ error = assert_raises(Pdf::FileNotFoundError) do
505
+ @component.render("/nonexistent/logo.png")
506
+ end
507
+ assert_match(/Logo not found/, error.message)
508
+ end
509
+ end
510
+
511
+ # test/pdf/components/qr_code_test.rb
512
+ # frozen_string_literal: true
513
+
514
+ require "test_helper"
515
+
516
+ class QrCodeComponentTest < Minitest::Test
517
+ def setup
518
+ @renderer = Pdf::Renderer.new.setup
519
+ @component = Pdf::Components::QrCode.new(@renderer.pdf)
520
+ end
521
+
522
+ def test_generates_qr_code_from_data
523
+ @component.render(data: "https://example.com")
524
+ end
525
+
526
+ def test_accepts_size_option
527
+ @component.render(data: "test", size: 100)
528
+ end
529
+ end
530
+
531
+ # test/pdf/components/spacer_test.rb
532
+ # frozen_string_literal: true
533
+
534
+ require "test_helper"
535
+
536
+ class SpacerComponentTest < Minitest::Test
537
+ def setup
538
+ @renderer = Pdf::Renderer.new.setup
539
+ @component = Pdf::Components::Spacer.new(@renderer.pdf)
540
+ end
541
+
542
+ def test_moves_down_by_default_amount
543
+ @component.render
544
+ # Default is 20, but we just verify no error
545
+ end
546
+
547
+ def test_accepts_custom_amount
548
+ @component.render(amount: 50)
549
+ end
550
+ end
551
+ ```