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