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,322 @@
1
+ # Phase 4: Builders (DSL)
2
+
3
+ All builders include `DynamicComponents` module for custom component support (DRY).
4
+
5
+ ## Files
6
+ - `lib/pdf/builders/content_builder.rb`
7
+ - `lib/pdf/builders/header_builder.rb`
8
+ - `lib/pdf/builders/footer_builder.rb`
9
+
10
+ ---
11
+
12
+ ## 4.1 ContentBuilder
13
+
14
+ Main DSL for view content. Includes conditional rendering via `render_if`/`render_unless`.
15
+
16
+ ```ruby
17
+ # lib/pdf/builders/content_builder.rb
18
+ # frozen_string_literal: true
19
+
20
+ module Pdf
21
+ module Builders
22
+ class ContentBuilder
23
+ include DynamicComponents
24
+
25
+ attr_reader :blueprint
26
+
27
+ def initialize(blueprint = Blueprint.new)
28
+ @blueprint = blueprint
29
+ end
30
+
31
+ def title(value, **options)
32
+ @blueprint.add(:title, value, **options)
33
+ end
34
+
35
+ def subtitle(value, **options)
36
+ @blueprint.add(:subtitle, value, **options)
37
+ end
38
+
39
+ def date(value, **options)
40
+ @blueprint.add(:date, value, **options)
41
+ end
42
+
43
+ def heading(value, **options)
44
+ @blueprint.add(:heading, value, **options)
45
+ end
46
+
47
+ def paragraph(value, **options)
48
+ @blueprint.add(:paragraph, value, **options)
49
+ end
50
+
51
+ def span(value, **options)
52
+ @blueprint.add(:span, value, **options)
53
+ end
54
+
55
+ def hr(**options)
56
+ @blueprint.add(:hr, **options)
57
+ end
58
+
59
+ def spacer(**options)
60
+ @blueprint.add(:spacer, **options)
61
+ end
62
+
63
+ def alert(source, **options)
64
+ @blueprint.add(:alert, source, **options)
65
+ end
66
+
67
+ def table(source, **options)
68
+ @blueprint.add(:table, source, **options)
69
+ end
70
+
71
+ def section(title_text, **options, &block)
72
+ nested = self.class.new
73
+ nested.instance_eval(&block) if block
74
+ @blueprint.add(:section, title_text, nested: nested.blueprint, **options)
75
+ end
76
+
77
+ def each(source, **options, &block)
78
+ @blueprint.add(:each, source, block: block, **options)
79
+ end
80
+
81
+ def partial(method_name)
82
+ @blueprint.add(:partial, method_name)
83
+ end
84
+
85
+ def page_break
86
+ @blueprint.add(:page_break)
87
+ end
88
+
89
+ def page_break_if(threshold:)
90
+ @blueprint.add(:page_break_if, threshold: threshold)
91
+ end
92
+
93
+ # Conditional rendering - render block only if condition is truthy
94
+ def render_if(condition, &block)
95
+ nested = self.class.new
96
+ nested.instance_eval(&block) if block
97
+ @blueprint.add(:render_if, condition, nested: nested.blueprint)
98
+ end
99
+
100
+ # Conditional rendering - render block only if condition is falsy
101
+ def render_unless(condition, &block)
102
+ nested = self.class.new
103
+ nested.instance_eval(&block) if block
104
+ @blueprint.add(:render_unless, condition, nested: nested.blueprint)
105
+ end
106
+
107
+ # Escape hatch for direct Prawn access
108
+ def raw(&block)
109
+ @blueprint.add(:raw, block: block)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 4.2 HeaderBuilder
119
+
120
+ DSL for layout headers. Uses DynamicComponents mixin.
121
+
122
+ ```ruby
123
+ # lib/pdf/builders/header_builder.rb
124
+ # frozen_string_literal: true
125
+
126
+ module Pdf
127
+ module Builders
128
+ class HeaderBuilder
129
+ include DynamicComponents
130
+
131
+ attr_reader :blueprint
132
+
133
+ def initialize
134
+ @blueprint = Blueprint.new
135
+ end
136
+
137
+ def logo(path, **options)
138
+ @blueprint.add(:logo, path, **options)
139
+ end
140
+
141
+ def qr_code(**options)
142
+ @blueprint.add(:qr_code, **options)
143
+ end
144
+
145
+ def context(source, **options)
146
+ @blueprint.add(:context, source, **options)
147
+ end
148
+
149
+ def text(value, **options)
150
+ @blueprint.add(:header_text, value, **options)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ ```
156
+
157
+ ---
158
+
159
+ ## 4.3 FooterBuilder
160
+
161
+ DSL for layout footers. Uses DynamicComponents mixin.
162
+
163
+ ```ruby
164
+ # lib/pdf/builders/footer_builder.rb
165
+ # frozen_string_literal: true
166
+
167
+ module Pdf
168
+ module Builders
169
+ class FooterBuilder
170
+ include DynamicComponents
171
+
172
+ attr_reader :blueprint
173
+
174
+ def initialize
175
+ @blueprint = Blueprint.new
176
+ end
177
+
178
+ def text(source, **options)
179
+ @blueprint.add(:footer_text, source, **options)
180
+ end
181
+
182
+ # Page number is handled by FooterEvaluator, not as a component
183
+ def page_number(**options)
184
+ @blueprint.add(:page_number, **options)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Tests
194
+
195
+ ```ruby
196
+ # test/pdf/builders/content_builder_test.rb
197
+ # frozen_string_literal: true
198
+
199
+ require "test_helper"
200
+
201
+ class ContentBuilderTest < Minitest::Test
202
+ def setup
203
+ @builder = Pdf::Builders::ContentBuilder.new
204
+ end
205
+
206
+ def test_title_adds_element
207
+ @builder.title("Report")
208
+ el = @builder.blueprint.elements.first
209
+ assert_equal :title, el[:type]
210
+ assert_equal ["Report"], el[:args]
211
+ end
212
+
213
+ def test_section_creates_nested_blueprint
214
+ @builder.section("Details") do
215
+ paragraph "Text"
216
+ table :items
217
+ end
218
+
219
+ el = @builder.blueprint.elements.first
220
+ assert_equal :section, el[:type]
221
+ assert_equal 2, el[:options][:nested].elements.size
222
+ end
223
+
224
+ def test_each_stores_block
225
+ @builder.each(:items) { |item| paragraph item[:name] }
226
+ el = @builder.blueprint.elements.first
227
+ assert_kind_of Proc, el[:options][:block]
228
+ end
229
+
230
+ def test_render_if_stores_condition_and_nested_blueprint
231
+ @builder.render_if(:has_items) do
232
+ table :items
233
+ end
234
+
235
+ el = @builder.blueprint.elements.first
236
+ assert_equal :render_if, el[:type]
237
+ assert_equal :has_items, el[:args].first
238
+ assert_equal 1, el[:options][:nested].elements.size
239
+ end
240
+
241
+ def test_render_unless_stores_condition_and_nested_blueprint
242
+ @builder.render_unless(:is_empty) do
243
+ paragraph "Not empty"
244
+ end
245
+
246
+ el = @builder.blueprint.elements.first
247
+ assert_equal :render_unless, el[:type]
248
+ assert_equal :is_empty, el[:args].first
249
+ end
250
+
251
+ def test_raw_stores_block_for_direct_prawn_access
252
+ @builder.raw { |pdf| pdf.stroke_circle [100, 100], 50 }
253
+ el = @builder.blueprint.elements.first
254
+ assert_equal :raw, el[:type]
255
+ assert_kind_of Proc, el[:options][:block]
256
+ end
257
+
258
+ def test_dynamic_components_via_mixin
259
+ Pdf.register_component(:badge, Class.new(Pdf::Component))
260
+ @builder.badge("NEW", color: "red")
261
+ el = @builder.blueprint.elements.first
262
+ assert_equal :badge, el[:type]
263
+ ensure
264
+ Pdf.instance_variable_get(:@components).delete(:badge)
265
+ end
266
+ end
267
+
268
+ # test/pdf/builders/header_builder_test.rb
269
+ # frozen_string_literal: true
270
+
271
+ require "test_helper"
272
+
273
+ class HeaderBuilderTest < Minitest::Test
274
+ def setup
275
+ @builder = Pdf::Builders::HeaderBuilder.new
276
+ end
277
+
278
+ def test_adds_logo
279
+ @builder.logo("logo.png", width: 100)
280
+ assert_equal :logo, @builder.blueprint.elements.first[:type]
281
+ end
282
+
283
+ def test_adds_qr_code
284
+ @builder.qr_code(data: "https://example.com")
285
+ assert_equal :qr_code, @builder.blueprint.elements.first[:type]
286
+ end
287
+
288
+ def test_adds_context
289
+ @builder.context(:context_lines)
290
+ assert_equal :context, @builder.blueprint.elements.first[:type]
291
+ end
292
+
293
+ def test_includes_dynamic_components
294
+ assert_includes Pdf::Builders::HeaderBuilder.ancestors, Pdf::DynamicComponents
295
+ end
296
+ end
297
+
298
+ # test/pdf/builders/footer_builder_test.rb
299
+ # frozen_string_literal: true
300
+
301
+ require "test_helper"
302
+
303
+ class FooterBuilderTest < Minitest::Test
304
+ def setup
305
+ @builder = Pdf::Builders::FooterBuilder.new
306
+ end
307
+
308
+ def test_adds_text
309
+ @builder.text(:footer_title, size: 8)
310
+ assert_equal :footer_text, @builder.blueprint.elements.first[:type]
311
+ end
312
+
313
+ def test_adds_page_number
314
+ @builder.page_number(position: :right)
315
+ assert_equal :page_number, @builder.blueprint.elements.first[:type]
316
+ end
317
+
318
+ def test_includes_dynamic_components
319
+ assert_includes Pdf::Builders::FooterBuilder.ancestors, Pdf::DynamicComponents
320
+ end
321
+ end
322
+ ```
@@ -0,0 +1,362 @@
1
+ # Phase 5: View & Layout
2
+
3
+ ## Files
4
+ - `lib/pdf/view.rb`
5
+ - `lib/pdf/layout.rb`
6
+
7
+ ---
8
+
9
+ ## 5.1 View
10
+
11
+ Base class for PDF views. Provides class-level DSL.
12
+
13
+ **Key fix: Proper inheritance** - Child views inherit parent's blueprint via `dup`, so they get all parent elements plus can add their own.
14
+
15
+ ```ruby
16
+ # lib/pdf/view.rb
17
+ # frozen_string_literal: true
18
+
19
+ module Pdf
20
+ class View
21
+ class << self
22
+ def inherited(subclass)
23
+ # CRITICAL: Inherit parent's blueprint via dup, not empty blueprint
24
+ # This allows child views to extend parent views
25
+ parent_blueprint = @blueprint || Blueprint.new
26
+ subclass.instance_variable_set(:@blueprint, parent_blueprint.dup)
27
+ subclass.instance_variable_set(:@content_builder, nil)
28
+ end
29
+
30
+ def blueprint
31
+ @blueprint ||= Blueprint.new
32
+ end
33
+
34
+ def layout(klass)
35
+ blueprint.set_layout(klass)
36
+ end
37
+
38
+ # DSL methods - delegate to ContentBuilder
39
+ %i[title subtitle date heading paragraph span hr spacer alert table page_break].each do |method|
40
+ define_method(method) do |*args, **options, &block|
41
+ content_builder.send(method, *args, **options, &block)
42
+ end
43
+ end
44
+
45
+ def section(title_text, **options, &block)
46
+ content_builder.section(title_text, **options, &block)
47
+ end
48
+
49
+ def each(source, **options, &block)
50
+ content_builder.each(source, **options, &block)
51
+ end
52
+
53
+ def partial(method_name)
54
+ content_builder.partial(method_name)
55
+ end
56
+
57
+ def page_break_if(threshold:)
58
+ content_builder.page_break_if(threshold: threshold)
59
+ end
60
+
61
+ def render_if(condition, &block)
62
+ content_builder.render_if(condition, &block)
63
+ end
64
+
65
+ def render_unless(condition, &block)
66
+ content_builder.render_unless(condition, &block)
67
+ end
68
+
69
+ def raw(&block)
70
+ content_builder.raw(&block)
71
+ end
72
+
73
+ # Dynamic component support via DynamicComponents pattern
74
+ def method_missing(name, *args, **options, &block)
75
+ if Pdf.component_registered?(name)
76
+ content_builder.send(name, *args, **options, &block)
77
+ else
78
+ super
79
+ end
80
+ end
81
+
82
+ def respond_to_missing?(name, include_private = false)
83
+ Pdf.component_registered?(name) || super
84
+ end
85
+
86
+ private
87
+
88
+ def content_builder
89
+ @content_builder ||= Builders::ContentBuilder.new(blueprint)
90
+ end
91
+ end
92
+
93
+ attr_reader :data
94
+
95
+ def initialize(data = {})
96
+ @data = data.is_a?(Hash) ? symbolize_keys(data) : data
97
+ end
98
+
99
+ def to_pdf
100
+ renderer = Renderer.new
101
+ layout_class = self.class.blueprint.layout_class
102
+
103
+ if layout_class
104
+ layout = layout_class.new(self)
105
+ layout.render(renderer)
106
+ else
107
+ renderer.setup
108
+ render_content(renderer)
109
+ end
110
+
111
+ renderer.finalize
112
+ end
113
+
114
+ def to_file(path)
115
+ File.binwrite(path, to_pdf)
116
+ end
117
+
118
+ def render_content(renderer)
119
+ evaluator = ContentEvaluator.new(self, renderer)
120
+ evaluator.evaluate(self.class.blueprint)
121
+ end
122
+
123
+ private
124
+
125
+ def symbolize_keys(hash)
126
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
127
+ end
128
+ end
129
+ end
130
+ ```
131
+
132
+ ---
133
+
134
+ ## 5.2 Layout
135
+
136
+ Base class for layouts. Defines header/footer/margins.
137
+
138
+ **Uses shared `Pdf::DEFAULT_MARGINS`** - same constant as Renderer for consistency.
139
+
140
+ ```ruby
141
+ # lib/pdf/layout.rb
142
+ # frozen_string_literal: true
143
+
144
+ module Pdf
145
+ class Layout
146
+ class << self
147
+ def inherited(subclass)
148
+ subclass.instance_variable_set(:@header_blueprint, nil)
149
+ subclass.instance_variable_set(:@footer_blueprint, nil)
150
+ subclass.instance_variable_set(:@margins_config, Pdf::DEFAULT_MARGINS.dup)
151
+ end
152
+
153
+ def header(&block)
154
+ builder = Builders::HeaderBuilder.new
155
+ builder.instance_eval(&block)
156
+ @header_blueprint = builder.blueprint
157
+ end
158
+
159
+ def footer(&block)
160
+ builder = Builders::FooterBuilder.new
161
+ builder.instance_eval(&block)
162
+ @footer_blueprint = builder.blueprint
163
+ end
164
+
165
+ def margins(**options)
166
+ @margins_config = Pdf::DEFAULT_MARGINS.merge(options)
167
+ end
168
+
169
+ attr_reader :header_blueprint, :footer_blueprint
170
+
171
+ def margins_config
172
+ @margins_config || Pdf::DEFAULT_MARGINS
173
+ end
174
+ end
175
+
176
+ attr_reader :view
177
+
178
+ def initialize(view)
179
+ @view = view
180
+ end
181
+
182
+ def render(renderer)
183
+ renderer.setup(margins: self.class.margins_config)
184
+
185
+ render_header(renderer) if self.class.header_blueprint
186
+ view.render_content(renderer)
187
+ setup_footer(renderer) if self.class.footer_blueprint
188
+ end
189
+
190
+ private
191
+
192
+ def render_header(renderer)
193
+ evaluator = HeaderEvaluator.new(view, renderer)
194
+ evaluator.evaluate(self.class.header_blueprint)
195
+ end
196
+
197
+ def setup_footer(renderer)
198
+ evaluator = FooterEvaluator.new(view, renderer)
199
+ evaluator.setup(self.class.footer_blueprint)
200
+ end
201
+ end
202
+ end
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Tests
208
+
209
+ ```ruby
210
+ # test/pdf/view_test.rb
211
+ # frozen_string_literal: true
212
+
213
+ require "test_helper"
214
+
215
+ class ViewTest < Minitest::Test
216
+ def test_class_level_dsl_stores_blueprint
217
+ view_class = Class.new(Pdf::View) do
218
+ title "Report"
219
+ paragraph :description
220
+
221
+ section "Items" do
222
+ table :items
223
+ end
224
+
225
+ def description
226
+ "Desc: #{data[:name]}"
227
+ end
228
+ end
229
+
230
+ assert_equal 3, view_class.blueprint.elements.size
231
+ end
232
+
233
+ def test_inheritance_child_inherits_parent_elements
234
+ parent = Class.new(Pdf::View) do
235
+ title "Parent Title"
236
+ paragraph "Parent content"
237
+ end
238
+
239
+ child = Class.new(parent) do
240
+ subtitle "Child Subtitle"
241
+ end
242
+
243
+ # Parent has 2 elements
244
+ assert_equal 2, parent.blueprint.elements.size
245
+ # Child has parent's 2 elements + its own 1 = 3 total
246
+ assert_equal 3, child.blueprint.elements.size
247
+ end
248
+
249
+ def test_inheritance_child_modifications_dont_affect_parent
250
+ parent = Class.new(Pdf::View) do
251
+ title "Parent Title"
252
+ paragraph "Parent content"
253
+ end
254
+
255
+ child = Class.new(parent) do
256
+ subtitle "Child Subtitle"
257
+ end
258
+
259
+ # Trigger child blueprint
260
+ child.blueprint
261
+ assert_equal 2, parent.blueprint.elements.size
262
+ end
263
+
264
+ def test_inheritance_preserves_element_order
265
+ parent = Class.new(Pdf::View) do
266
+ title "Parent Title"
267
+ paragraph "Parent content"
268
+ end
269
+
270
+ child = Class.new(parent) do
271
+ subtitle "Child Subtitle"
272
+ end
273
+
274
+ elements = child.blueprint.elements
275
+ assert_equal :title, elements[0][:type]
276
+ assert_equal :paragraph, elements[1][:type]
277
+ assert_equal :subtitle, elements[2][:type]
278
+ end
279
+
280
+ def test_render_if_and_render_unless_store_conditional_elements
281
+ view_class = Class.new(Pdf::View) do
282
+ render_if(:show_header) { title "Conditional Title" }
283
+ render_unless(:hide_footer) { paragraph "Footer" }
284
+ end
285
+
286
+ assert_equal 2, view_class.blueprint.elements.size
287
+ assert_equal :render_if, view_class.blueprint.elements[0][:type]
288
+ assert_equal :render_unless, view_class.blueprint.elements[1][:type]
289
+ end
290
+
291
+ def test_initialize_symbolizes_string_keys
292
+ view = Pdf::View.new("name" => "Test")
293
+ assert_equal "Test", view.data[:name]
294
+ end
295
+
296
+ def test_to_pdf_generates_pdf
297
+ view_class = Class.new(Pdf::View) do
298
+ title "Test"
299
+ end
300
+
301
+ pdf = view_class.new.to_pdf
302
+ assert pdf.start_with?("%PDF")
303
+ end
304
+ end
305
+
306
+ # test/pdf/layout_test.rb
307
+ # frozen_string_literal: true
308
+
309
+ require "test_helper"
310
+
311
+ class LayoutTest < Minitest::Test
312
+ def test_stores_header_blueprint
313
+ layout_class = Class.new(Pdf::Layout) do
314
+ header do
315
+ logo "logo.png"
316
+ context :lines
317
+ end
318
+ end
319
+
320
+ assert_equal 2, layout_class.header_blueprint.elements.size
321
+ end
322
+
323
+ def test_stores_footer_blueprint
324
+ layout_class = Class.new(Pdf::Layout) do
325
+ footer do
326
+ text :title
327
+ page_number
328
+ end
329
+ end
330
+
331
+ assert_equal 2, layout_class.footer_blueprint.elements.size
332
+ end
333
+
334
+ def test_stores_margins
335
+ layout_class = Class.new(Pdf::Layout) do
336
+ margins top: 80, bottom: 60
337
+ end
338
+
339
+ assert_equal 80, layout_class.margins_config[:top]
340
+ assert_equal 60, layout_class.margins_config[:bottom]
341
+ end
342
+
343
+ def test_uses_default_margins_as_base
344
+ layout_class = Class.new(Pdf::Layout) do
345
+ margins top: 80, bottom: 60
346
+ end
347
+
348
+ assert_equal Pdf::DEFAULT_MARGINS[:right], layout_class.margins_config[:right]
349
+ assert_equal Pdf::DEFAULT_MARGINS[:left], layout_class.margins_config[:left]
350
+ end
351
+
352
+ def test_render_generates_pdf
353
+ layout_class = Class.new(Pdf::Layout)
354
+ view_class = Class.new(Pdf::View) do
355
+ layout layout_class
356
+ end
357
+
358
+ pdf = view_class.new.to_pdf
359
+ assert pdf.start_with?("%PDF")
360
+ end
361
+ end
362
+ ```