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,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
|
+
```
|