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,494 @@
1
+ # Phase 6: Evaluators
2
+
3
+ ## Files
4
+ - `lib/pdf/content_evaluator.rb`
5
+ - `lib/pdf/header_evaluator.rb`
6
+ - `lib/pdf/footer_evaluator.rb`
7
+
8
+ ---
9
+
10
+ ## 6.1 ContentEvaluator
11
+
12
+ Evaluates view blueprint, resolves symbols, dispatches to components.
13
+
14
+ **New features:**
15
+ - `render_if` / `render_unless` for conditional rendering
16
+ - `raw` for direct Prawn access
17
+ - `date` now uses real Date component
18
+ - `spacer` support
19
+
20
+ ```ruby
21
+ # lib/pdf/content_evaluator.rb
22
+ # frozen_string_literal: true
23
+
24
+ module Pdf
25
+ class ContentEvaluator
26
+ def initialize(context, renderer)
27
+ @context = context
28
+ @renderer = renderer
29
+ @resolver = Resolver.new(context)
30
+ end
31
+
32
+ def evaluate(blueprint)
33
+ blueprint.elements.each { |el| evaluate_element(el) }
34
+ end
35
+
36
+ private
37
+
38
+ def evaluate_element(element)
39
+ type = element[:type]
40
+ args = element[:args]
41
+ options = element[:options]
42
+
43
+ case type
44
+ when :title, :subtitle, :heading, :paragraph, :span
45
+ render_text_component(type, args.first, options)
46
+
47
+ when :date
48
+ render_date(args.first, options)
49
+
50
+ when :hr
51
+ component(:hr).render(**options)
52
+
53
+ when :spacer
54
+ component(:spacer).render(**options)
55
+
56
+ when :alert
57
+ render_alert(args.first, options)
58
+
59
+ when :table
60
+ render_table(args.first, options)
61
+
62
+ when :section
63
+ render_section(args.first, options)
64
+
65
+ when :each
66
+ render_each(args.first, options)
67
+
68
+ when :partial
69
+ @context.send(args.first, @renderer)
70
+
71
+ when :page_break
72
+ @renderer.start_new_page
73
+
74
+ when :page_break_if
75
+ threshold = options[:threshold]
76
+ @renderer.start_new_page if @renderer.cursor < (@renderer.bounds.height * threshold)
77
+
78
+ when :render_if
79
+ render_conditional(args.first, options[:nested], truthy: true)
80
+
81
+ when :render_unless
82
+ render_conditional(args.first, options[:nested], truthy: false)
83
+
84
+ when :raw
85
+ # Escape hatch for direct Prawn access
86
+ block = options[:block]
87
+ @renderer.raw(&block) if block
88
+
89
+ else
90
+ # Try registered component
91
+ render_custom_component(type, args, options)
92
+ end
93
+ end
94
+
95
+ def render_text_component(type, source, options)
96
+ content = @resolver.resolve(source)
97
+ component(type).render(content, **options)
98
+ end
99
+
100
+ def render_date(source, options)
101
+ value = @resolver.resolve(source)
102
+ component(:date).render(value, **options)
103
+ end
104
+
105
+ def render_alert(source, options)
106
+ data = @resolver.resolve(source)
107
+
108
+ if data.is_a?(Hash)
109
+ component(:alert).render(
110
+ title: data[:title] || data["title"],
111
+ description: data[:description] || data["description"] || data[:subtitle] || data["subtitle"],
112
+ color: data[:color] || data["color"] || options[:color] || "blue"
113
+ )
114
+ else
115
+ component(:alert).render(title: data.to_s, color: options[:color] || "blue")
116
+ end
117
+ end
118
+
119
+ def render_table(source, options)
120
+ data = @resolver.resolve(source)
121
+ columns = options[:columns]
122
+ widths = options[:widths]
123
+
124
+ headers, rows = extract_table_data(data, columns, options)
125
+ component(:table).render(headers: headers, rows: rows, widths: widths)
126
+ end
127
+
128
+ def extract_table_data(data, columns, options)
129
+ if data.is_a?(Array) && data.first.is_a?(Hash) && columns
130
+ headers = columns.map { |c| c.is_a?(Hash) ? c[:label] : c.to_s.capitalize }
131
+ rows = data.map do |row|
132
+ columns.map do |col|
133
+ key = col.is_a?(Hash) ? col[:key] : col
134
+ row[key] || row[key.to_s] || "-"
135
+ end
136
+ end
137
+ [headers, rows]
138
+
139
+ elsif data.is_a?(Hash) && data[:headers] && data[:rows]
140
+ [data[:headers], data[:rows]]
141
+
142
+ elsif data.is_a?(Array) && data.first.is_a?(Array)
143
+ if options[:headers]
144
+ [options[:headers], data]
145
+ else
146
+ [data.first, data[1..]]
147
+ end
148
+
149
+ else
150
+ [["Key", "Value"], data.to_a]
151
+ end
152
+ end
153
+
154
+ def render_section(title, options)
155
+ component(:heading).render(title, size: 12)
156
+ evaluate(options[:nested]) if options[:nested]
157
+ end
158
+
159
+ def render_each(source, options)
160
+ collection = @resolver.resolve(source)
161
+ block = options[:block]
162
+ return if collection.nil? || collection.empty?
163
+
164
+ collection.each do |item|
165
+ item_context = EachItemContext.new(@context, item)
166
+ sub_evaluator = self.class.new(item_context, @renderer)
167
+
168
+ builder = Builders::ContentBuilder.new
169
+ builder.instance_exec(item, &block)
170
+ sub_evaluator.evaluate(builder.blueprint)
171
+ end
172
+ end
173
+
174
+ def render_conditional(condition, nested_blueprint, truthy:)
175
+ return unless nested_blueprint
176
+
177
+ condition_value = @resolver.resolve(condition)
178
+ should_render = truthy ? condition_value : !condition_value
179
+
180
+ evaluate(nested_blueprint) if should_render
181
+ end
182
+
183
+ def render_custom_component(type, args, options)
184
+ return unless Pdf.component_registered?(type)
185
+
186
+ comp = component(type)
187
+ if args.empty?
188
+ comp.render(**options)
189
+ else
190
+ resolved_args = args.map { |a| @resolver.resolve(a) }
191
+ comp.render(*resolved_args, **options)
192
+ end
193
+ end
194
+
195
+ def component(type)
196
+ Pdf.component(type).new(@renderer.pdf)
197
+ end
198
+ end
199
+
200
+ # Context wrapper for each iteration
201
+ class EachItemContext
202
+ def initialize(parent, item)
203
+ @parent = parent
204
+ @item = item
205
+ end
206
+
207
+ def data
208
+ @parent.data
209
+ end
210
+
211
+ attr_reader :item
212
+
213
+ def method_missing(method, *args, &block)
214
+ if @item.respond_to?(method)
215
+ @item.send(method, *args, &block)
216
+ elsif @item.is_a?(Hash) && (@item.key?(method) || @item.key?(method.to_s))
217
+ @item[method] || @item[method.to_s]
218
+ else
219
+ @parent.send(method, *args, &block)
220
+ end
221
+ end
222
+
223
+ def respond_to_missing?(method, include_private = false)
224
+ @item.respond_to?(method) ||
225
+ (@item.is_a?(Hash) && (@item.key?(method) || @item.key?(method.to_s))) ||
226
+ @parent.respond_to?(method, include_private)
227
+ end
228
+ end
229
+ end
230
+ ```
231
+
232
+ ---
233
+
234
+ ## 6.2 HeaderEvaluator
235
+
236
+ Evaluates header blueprint.
237
+
238
+ **QR code now takes `data:` param** - generates from data, not file path.
239
+
240
+ ```ruby
241
+ # lib/pdf/header_evaluator.rb
242
+ # frozen_string_literal: true
243
+
244
+ module Pdf
245
+ class HeaderEvaluator
246
+ def initialize(context, renderer)
247
+ @context = context
248
+ @renderer = renderer
249
+ @resolver = Resolver.new(context)
250
+ end
251
+
252
+ def evaluate(blueprint)
253
+ blueprint.elements.each { |el| evaluate_element(el) }
254
+ end
255
+
256
+ private
257
+
258
+ def evaluate_element(element)
259
+ type = element[:type]
260
+ args = element[:args]
261
+ options = element[:options]
262
+
263
+ case type
264
+ when :logo
265
+ component(:logo).render(args.first, **options)
266
+
267
+ when :qr_code
268
+ # QR code generates from data, not file path
269
+ data = @resolver.resolve(options[:data])
270
+ component(:qr_code).render(data: data, **options.except(:data))
271
+
272
+ when :context
273
+ lines = @resolver.resolve(args.first)
274
+ component(:context).render(lines, **options)
275
+
276
+ when :header_text
277
+ content = @resolver.resolve(args.first)
278
+ component(:paragraph).render(content, **options)
279
+
280
+ else
281
+ render_custom_component(type, args, options)
282
+ end
283
+ end
284
+
285
+ def render_custom_component(type, args, options)
286
+ return unless Pdf.component_registered?(type)
287
+
288
+ comp = component(type)
289
+ resolved_args = args.map { |a| @resolver.resolve(a) }
290
+ comp.render(*resolved_args, **options)
291
+ end
292
+
293
+ def component(type)
294
+ Pdf.component(type).new(@renderer.pdf)
295
+ end
296
+ end
297
+ end
298
+ ```
299
+
300
+ ---
301
+
302
+ ## 6.3 FooterEvaluator
303
+
304
+ Sets up footer (deferred rendering via repeat).
305
+
306
+ **Page numbers handled here** - not as a separate component. This is the right place since page_count isn't known until all content is rendered.
307
+
308
+ ```ruby
309
+ # lib/pdf/footer_evaluator.rb
310
+ # frozen_string_literal: true
311
+
312
+ module Pdf
313
+ class FooterEvaluator
314
+ def initialize(context, renderer)
315
+ @context = context
316
+ @renderer = renderer
317
+ @resolver = Resolver.new(context)
318
+ end
319
+
320
+ def setup(blueprint)
321
+ texts = []
322
+ page_number_opts = nil
323
+
324
+ blueprint.elements.each do |el|
325
+ case el[:type]
326
+ when :footer_text
327
+ content = @resolver.resolve(el[:args].first)
328
+ texts << { text: content, size: el[:options][:size] || 8 }
329
+
330
+ when :page_number
331
+ page_number_opts = el[:options]
332
+ end
333
+ end
334
+
335
+ @renderer.setup_footer do |pdf|
336
+ render_footer_content(pdf, texts, page_number_opts)
337
+ end
338
+ end
339
+
340
+ private
341
+
342
+ def render_footer_content(pdf, texts, page_number_opts)
343
+ # Text lines
344
+ unless texts.empty?
345
+ pdf.repeat(:all) do
346
+ pdf.canvas do
347
+ pdf.bounding_box([36, 60], width: pdf.bounds.absolute_right - 72, height: 40) do
348
+ y = 30
349
+ texts.each do |t|
350
+ pdf.draw_text t[:text].to_s, at: [0, y], size: t[:size]
351
+ y -= 12
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
357
+
358
+ # Page numbers
359
+ return unless page_number_opts
360
+
361
+ position = page_number_opts[:position] || :right
362
+ size = page_number_opts[:size] || 8
363
+ fmt = page_number_opts[:format] || "Page %<page>d / %<total>d"
364
+
365
+ pdf.repeat(:all, dynamic: true) do
366
+ pdf.canvas do
367
+ page_str = format(fmt, page: pdf.page_number, total: pdf.page_count)
368
+ text_w = pdf.width_of(page_str, size: size)
369
+ x = position == :right ? pdf.bounds.absolute_right - 36 - text_w : 36
370
+ pdf.draw_text page_str, at: [x, 24], size: size
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ ```
377
+
378
+ ---
379
+
380
+ ## Tests
381
+
382
+ ```ruby
383
+ # test/pdf/content_evaluator_test.rb
384
+ # frozen_string_literal: true
385
+
386
+ require "test_helper"
387
+
388
+ class ContentEvaluatorTest < Minitest::Test
389
+ def setup
390
+ @view_class = Class.new(Pdf::View) do
391
+ title :report_title
392
+ paragraph "Static text"
393
+ alert :summary
394
+
395
+ def report_title = "Dynamic Title"
396
+ def summary = { title: "OK", color: "green" }
397
+ end
398
+
399
+ @view = @view_class.new
400
+ @renderer = Pdf::Renderer.new.setup
401
+ @evaluator = Pdf::ContentEvaluator.new(@view, @renderer)
402
+ end
403
+
404
+ def test_evaluates_blueprint
405
+ @evaluator.evaluate(@view_class.blueprint)
406
+ end
407
+
408
+ def test_resolves_symbols
409
+ @evaluator.evaluate(@view_class.blueprint)
410
+ pdf = @renderer.finalize
411
+ assert_includes pdf, "Dynamic Title"
412
+ end
413
+
414
+ def test_render_if_renders_when_condition_is_truthy
415
+ conditional_view = Class.new(Pdf::View) do
416
+ render_if(:show_title) { title "Conditional" }
417
+ def show_title = data[:show]
418
+ end
419
+
420
+ view = conditional_view.new(show: true)
421
+ pdf = view.to_pdf
422
+ assert_includes pdf, "Conditional"
423
+ end
424
+
425
+ def test_render_if_skips_when_condition_is_falsy
426
+ conditional_view = Class.new(Pdf::View) do
427
+ render_if(:show_title) { title "Conditional" }
428
+ def show_title = data[:show]
429
+ end
430
+
431
+ view = conditional_view.new(show: false)
432
+ pdf = view.to_pdf
433
+ refute_includes pdf, "Conditional"
434
+ end
435
+
436
+ def test_render_unless_renders_when_condition_is_falsy
437
+ conditional_view = Class.new(Pdf::View) do
438
+ render_unless(:hide_content) { title "Visible" }
439
+ def hide_content = data[:hidden]
440
+ end
441
+
442
+ view = conditional_view.new(hidden: false)
443
+ pdf = view.to_pdf
444
+ assert_includes pdf, "Visible"
445
+ end
446
+
447
+ def test_render_unless_skips_when_condition_is_truthy
448
+ conditional_view = Class.new(Pdf::View) do
449
+ render_unless(:hide_content) { title "Visible" }
450
+ def hide_content = data[:hidden]
451
+ end
452
+
453
+ view = conditional_view.new(hidden: true)
454
+ pdf = view.to_pdf
455
+ refute_includes pdf, "Visible"
456
+ end
457
+
458
+ def test_raw_executes_block_with_prawn_document
459
+ raw_view = Class.new(Pdf::View) do
460
+ raw { |pdf| pdf.stroke_circle [100, 100], 50 }
461
+ end
462
+
463
+ raw_view.new.to_pdf
464
+ end
465
+ end
466
+
467
+ # test/pdf/each_item_context_test.rb
468
+ # frozen_string_literal: true
469
+
470
+ require "test_helper"
471
+
472
+ class EachItemContextTest < Minitest::Test
473
+ def setup
474
+ @parent = Object.new
475
+ def @parent.data = { key: "parent_value" }
476
+ def @parent.parent_method = "from_parent"
477
+
478
+ @item = { name: "Item", value: 42 }
479
+ @context = Pdf::EachItemContext.new(@parent, @item)
480
+ end
481
+
482
+ def test_resolves_item_keys
483
+ assert_equal "Item", @context.name
484
+ end
485
+
486
+ def test_falls_back_to_parent
487
+ assert_equal "from_parent", @context.parent_method
488
+ end
489
+
490
+ def test_exposes_item
491
+ assert_equal @item, @context.item
492
+ end
493
+ end
494
+ ```