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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a65082843a9b93d0e95848671c5e58ba2921e838c168a76dd9cca4b14cd76044
|
|
4
|
+
data.tar.gz: a6dcf50015479680b09fc8a36039467fa188442781622e086ea12a0a43bbfb5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 306faa44b40d9be836752330a24198c7897892fc0b149e801fc08f72bfb91775f754c62e7a9e7b22cb28ea170389ffe6842e5153713b6d05181e10d915e3e344
|
|
7
|
+
data.tar.gz: dda4d0483b77b8a3ba2371e1bca8d4556d33ecaeb3c71a444b88869eb28ebfaab856ae99f9a993425b4edc6976fac425e978a5a4f885578b1d390b6ed2a8558d
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# PDF Gem - Execution Overview
|
|
2
|
+
|
|
3
|
+
## Architecture
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌─────────────────────────────────────────────────────────┐
|
|
7
|
+
│ User Code │
|
|
8
|
+
│ class MyReport < Pdf::View │
|
|
9
|
+
│ title "Report" │
|
|
10
|
+
│ table :items │
|
|
11
|
+
│ end │
|
|
12
|
+
└─────────────────────────────────────────────────────────┘
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
┌─────────────────────────────────────────────────────────┐
|
|
16
|
+
│ Builders (DSL) │
|
|
17
|
+
│ ContentBuilder, HeaderBuilder, FooterBuilder │
|
|
18
|
+
│ Translate DSL calls → Blueprint │
|
|
19
|
+
│ (uses DynamicComponents mixin for extensibility) │
|
|
20
|
+
└─────────────────────────────────────────────────────────┘
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
┌─────────────────────────────────────────────────────────┐
|
|
24
|
+
│ Blueprint │
|
|
25
|
+
│ Stores element declarations as data │
|
|
26
|
+
│ [{type: :title, args: ["Report"], options: {}}] │
|
|
27
|
+
└─────────────────────────────────────────────────────────┘
|
|
28
|
+
│
|
|
29
|
+
▼
|
|
30
|
+
┌─────────────────────────────────────────────────────────┐
|
|
31
|
+
│ Evaluators │
|
|
32
|
+
│ ContentEvaluator, HeaderEvaluator, FooterEvaluator │
|
|
33
|
+
│ Resolve symbols, dispatch to components │
|
|
34
|
+
│ Handle conditionals (render_if), page numbers │
|
|
35
|
+
└─────────────────────────────────────────────────────────┘
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
┌─────────────────────────────────────────────────────────┐
|
|
39
|
+
│ Components │
|
|
40
|
+
│ Title, Paragraph, Table, Alert, Hr, Date, QrCode... │
|
|
41
|
+
│ Each component renders using primitives │
|
|
42
|
+
│ NEVER swallow errors - always raise │
|
|
43
|
+
└─────────────────────────────────────────────────────────┘
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
┌─────────────────────────────────────────────────────────┐
|
|
47
|
+
│ Renderer (Primitives) │
|
|
48
|
+
│ text, box, line, image, table_data, raw │
|
|
49
|
+
│ Thin wrapper over Prawn │
|
|
50
|
+
└─────────────────────────────────────────────────────────┘
|
|
51
|
+
│
|
|
52
|
+
▼
|
|
53
|
+
┌─────────────────────────────────────────────────────────┐
|
|
54
|
+
│ Prawn │
|
|
55
|
+
│ PDF generation │
|
|
56
|
+
└─────────────────────────────────────────────────────────┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Design Principles
|
|
60
|
+
|
|
61
|
+
1. **No silent failures** - All errors bubble up. Client code handles exceptions.
|
|
62
|
+
2. **Proper inheritance** - Child views inherit parent blueprint via `dup`.
|
|
63
|
+
3. **DRY** - Shared behavior extracted to modules (e.g., `DynamicComponents`).
|
|
64
|
+
4. **Escape hatches** - `raw` block for direct Prawn access when needed.
|
|
65
|
+
|
|
66
|
+
## Execution Phases
|
|
67
|
+
|
|
68
|
+
| Phase | Focus | Files |
|
|
69
|
+
|-------|-------|-------|
|
|
70
|
+
| 01 | Core Infrastructure | blueprint.rb, resolver.rb, component.rb, dynamic_components.rb |
|
|
71
|
+
| 02 | Renderer Primitives | renderer.rb |
|
|
72
|
+
| 03 | Built-in Components | components/*.rb |
|
|
73
|
+
| 04 | Builders (DSL) | builders/*.rb |
|
|
74
|
+
| 05 | View & Layout | view.rb, layout.rb |
|
|
75
|
+
| 06 | Evaluators | *_evaluator.rb |
|
|
76
|
+
| 07 | Entry Point & Extensibility | pdf.rb, register_component |
|
|
77
|
+
|
|
78
|
+
## Dependencies
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
spec.add_dependency "prawn", "~> 2.4"
|
|
82
|
+
spec.add_dependency "prawn-table", "~> 0.2"
|
|
83
|
+
spec.add_dependency "rqrcode", "~> 2.0" # QR code generation
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## File Structure
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
lib/
|
|
90
|
+
pdf.rb
|
|
91
|
+
pdf/
|
|
92
|
+
version.rb
|
|
93
|
+
blueprint.rb
|
|
94
|
+
resolver.rb
|
|
95
|
+
component.rb
|
|
96
|
+
dynamic_components.rb
|
|
97
|
+
renderer.rb
|
|
98
|
+
view.rb
|
|
99
|
+
layout.rb
|
|
100
|
+
content_evaluator.rb
|
|
101
|
+
header_evaluator.rb
|
|
102
|
+
footer_evaluator.rb
|
|
103
|
+
builders/
|
|
104
|
+
content_builder.rb
|
|
105
|
+
header_builder.rb
|
|
106
|
+
footer_builder.rb
|
|
107
|
+
components/
|
|
108
|
+
title.rb
|
|
109
|
+
subtitle.rb
|
|
110
|
+
heading.rb
|
|
111
|
+
paragraph.rb
|
|
112
|
+
span.rb
|
|
113
|
+
date.rb
|
|
114
|
+
hr.rb
|
|
115
|
+
spacer.rb
|
|
116
|
+
alert.rb
|
|
117
|
+
table.rb
|
|
118
|
+
logo.rb
|
|
119
|
+
qr_code.rb
|
|
120
|
+
context.rb
|
|
121
|
+
```
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Phase 1: Core Infrastructure
|
|
2
|
+
|
|
3
|
+
## Files
|
|
4
|
+
- `lib/pdf/blueprint.rb`
|
|
5
|
+
- `lib/pdf/resolver.rb`
|
|
6
|
+
- `lib/pdf/component.rb`
|
|
7
|
+
- `lib/pdf/dynamic_components.rb`
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1.1 Blueprint
|
|
12
|
+
|
|
13
|
+
Storage for DSL declarations. Elements stored as data, evaluated later.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# lib/pdf/blueprint.rb
|
|
17
|
+
# frozen_string_literal: true
|
|
18
|
+
|
|
19
|
+
module Pdf
|
|
20
|
+
class Blueprint
|
|
21
|
+
attr_reader :elements, :layout_class, :metadata
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@elements = []
|
|
25
|
+
@layout_class = nil
|
|
26
|
+
@metadata = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(type, *args, **options, &block)
|
|
30
|
+
element = { type: type, args: args, options: options }
|
|
31
|
+
element[:block] = block if block
|
|
32
|
+
@elements << element
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def set_layout(klass)
|
|
36
|
+
@layout_class = klass
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def set_metadata(key, value)
|
|
40
|
+
@metadata[key] = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def empty?
|
|
44
|
+
@elements.empty? && @layout_class.nil? && @metadata.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dup
|
|
48
|
+
copy = Blueprint.new
|
|
49
|
+
copy.instance_variable_set(:@elements, deep_dup_elements)
|
|
50
|
+
copy.instance_variable_set(:@layout_class, @layout_class)
|
|
51
|
+
copy.instance_variable_set(:@metadata, @metadata.dup)
|
|
52
|
+
copy
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def deep_dup_elements
|
|
58
|
+
@elements.map do |el|
|
|
59
|
+
duped = el.dup
|
|
60
|
+
duped[:args] = el[:args].dup
|
|
61
|
+
duped[:options] = el[:options].dup
|
|
62
|
+
duped
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 1.2 Resolver
|
|
72
|
+
|
|
73
|
+
Resolves symbols to values at render time.
|
|
74
|
+
|
|
75
|
+
Resolution order:
|
|
76
|
+
1. Method on context → call it
|
|
77
|
+
2. Key in `data` hash → return value
|
|
78
|
+
3. Raise `ResolutionError`
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# lib/pdf/resolver.rb
|
|
82
|
+
# frozen_string_literal: true
|
|
83
|
+
|
|
84
|
+
module Pdf
|
|
85
|
+
class ResolutionError < StandardError; end
|
|
86
|
+
|
|
87
|
+
class Resolver
|
|
88
|
+
def initialize(context)
|
|
89
|
+
@context = context
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve(value)
|
|
93
|
+
case value
|
|
94
|
+
when Symbol then resolve_symbol(value)
|
|
95
|
+
when Proc then @context.instance_exec(&value)
|
|
96
|
+
when Array then value.map { |v| resolve(v) }
|
|
97
|
+
when Hash then value.transform_values { |v| resolve(v) }
|
|
98
|
+
else value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def resolve_symbol(sym)
|
|
105
|
+
if @context.respond_to?(sym, true)
|
|
106
|
+
return @context.send(sym)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if @context.respond_to?(:data) && @context.data.is_a?(Hash)
|
|
110
|
+
return @context.data[sym] if @context.data.key?(sym)
|
|
111
|
+
return @context.data[sym.to_s] if @context.data.key?(sym.to_s)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raise ResolutionError, "Cannot resolve :#{sym}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 1.3 Component Base
|
|
123
|
+
|
|
124
|
+
Base class for all components. Components receive Prawn document and render using primitives.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# lib/pdf/component.rb
|
|
128
|
+
# frozen_string_literal: true
|
|
129
|
+
|
|
130
|
+
module Pdf
|
|
131
|
+
class Component
|
|
132
|
+
attr_reader :pdf
|
|
133
|
+
|
|
134
|
+
def initialize(pdf)
|
|
135
|
+
@pdf = pdf
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render(...)
|
|
139
|
+
raise NotImplementedError, "#{self.class}#render not implemented"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Primitive helpers (delegate to pdf)
|
|
145
|
+
|
|
146
|
+
def text(content, **options)
|
|
147
|
+
@pdf.text content.to_s, **options
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def move_down(amount)
|
|
151
|
+
@pdf.move_down amount
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def stroke_horizontal_rule
|
|
155
|
+
@pdf.stroke_horizontal_rule
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def bounding_box(point, **options, &block)
|
|
159
|
+
@pdf.bounding_box(point, **options, &block)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def cursor
|
|
163
|
+
@pdf.cursor
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def bounds
|
|
167
|
+
@pdf.bounds
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 1.4 DynamicComponents Module
|
|
176
|
+
|
|
177
|
+
Shared mixin for dynamic component support. DRYs up method_missing across builders and views.
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# lib/pdf/dynamic_components.rb
|
|
181
|
+
# frozen_string_literal: true
|
|
182
|
+
|
|
183
|
+
module Pdf
|
|
184
|
+
module DynamicComponents
|
|
185
|
+
def method_missing(name, *args, **options, &block)
|
|
186
|
+
if Pdf.component_registered?(name)
|
|
187
|
+
@blueprint.add(name, *args, **options, &block)
|
|
188
|
+
else
|
|
189
|
+
super
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def respond_to_missing?(name, include_private = false)
|
|
194
|
+
Pdf.component_registered?(name) || super
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Tests
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# test/pdf/blueprint_test.rb
|
|
206
|
+
# frozen_string_literal: true
|
|
207
|
+
|
|
208
|
+
require "test_helper"
|
|
209
|
+
|
|
210
|
+
class BlueprintTest < Minitest::Test
|
|
211
|
+
def setup
|
|
212
|
+
@blueprint = Pdf::Blueprint.new
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def test_add_stores_element
|
|
216
|
+
@blueprint.add(:title, "Hello", size: 20)
|
|
217
|
+
element = @blueprint.elements.first
|
|
218
|
+
assert_equal :title, element[:type]
|
|
219
|
+
assert_equal ["Hello"], element[:args]
|
|
220
|
+
assert_equal({ size: 20 }, element[:options])
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def test_add_stores_block
|
|
224
|
+
@blueprint.add(:section, "Items") { table :items }
|
|
225
|
+
assert_kind_of Proc, @blueprint.elements.first[:block]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def test_dup_creates_independent_copy
|
|
229
|
+
@blueprint.add(:title, "Original")
|
|
230
|
+
copy = @blueprint.dup
|
|
231
|
+
copy.add(:title, "Copy")
|
|
232
|
+
assert_equal 1, @blueprint.elements.size
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def test_empty_returns_true_for_new_blueprint
|
|
236
|
+
assert @blueprint.empty?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def test_empty_returns_false_after_adding_element
|
|
240
|
+
@blueprint.add(:title, "Hello")
|
|
241
|
+
refute @blueprint.empty?
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# test/pdf/resolver_test.rb
|
|
246
|
+
# frozen_string_literal: true
|
|
247
|
+
|
|
248
|
+
require "test_helper"
|
|
249
|
+
|
|
250
|
+
class ResolverTest < Minitest::Test
|
|
251
|
+
def setup
|
|
252
|
+
context_class = Class.new do
|
|
253
|
+
attr_reader :data
|
|
254
|
+
def initialize(data) = @data = data
|
|
255
|
+
def computed = "computed:#{data[:raw]}"
|
|
256
|
+
end
|
|
257
|
+
@context = context_class.new({ raw: "value", key: "data_value" })
|
|
258
|
+
@resolver = Pdf::Resolver.new(@context)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def test_returns_strings_as_is
|
|
262
|
+
assert_equal "hello", @resolver.resolve("hello")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def test_resolves_method
|
|
266
|
+
assert_equal "computed:value", @resolver.resolve(:computed)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def test_resolves_data_key
|
|
270
|
+
assert_equal "data_value", @resolver.resolve(:key)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def test_raises_on_unknown
|
|
274
|
+
assert_raises(Pdf::ResolutionError) { @resolver.resolve(:unknown) }
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# test/pdf/component_test.rb
|
|
279
|
+
# frozen_string_literal: true
|
|
280
|
+
|
|
281
|
+
require "test_helper"
|
|
282
|
+
|
|
283
|
+
class ComponentTest < Minitest::Test
|
|
284
|
+
def test_requires_render_implementation
|
|
285
|
+
pdf = Object.new
|
|
286
|
+
component = Pdf::Component.new(pdf)
|
|
287
|
+
assert_raises(NotImplementedError) { component.render }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# test/pdf/dynamic_components_test.rb
|
|
292
|
+
# frozen_string_literal: true
|
|
293
|
+
|
|
294
|
+
require "test_helper"
|
|
295
|
+
|
|
296
|
+
class DynamicComponentsTest < Minitest::Test
|
|
297
|
+
def setup
|
|
298
|
+
@builder_class = Class.new do
|
|
299
|
+
include Pdf::DynamicComponents
|
|
300
|
+
attr_reader :blueprint
|
|
301
|
+
|
|
302
|
+
def initialize
|
|
303
|
+
@blueprint = Pdf::Blueprint.new
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
Pdf.register_component(:badge, Class.new(Pdf::Component))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def teardown
|
|
310
|
+
Pdf.instance_variable_get(:@components).delete(:badge)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def test_handles_registered_components
|
|
314
|
+
builder = @builder_class.new
|
|
315
|
+
builder.badge("NEW", color: "red")
|
|
316
|
+
assert_equal :badge, builder.blueprint.elements.first[:type]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def test_raises_for_unknown_methods
|
|
320
|
+
builder = @builder_class.new
|
|
321
|
+
assert_raises(NoMethodError) { builder.unknown_thing }
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
```
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# Phase 2: Renderer (Primitives)
|
|
2
|
+
|
|
3
|
+
## Files
|
|
4
|
+
- `lib/pdf/renderer.rb`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Renderer
|
|
9
|
+
|
|
10
|
+
Thin wrapper over Prawn. Provides primitives that components use.
|
|
11
|
+
|
|
12
|
+
**Key principles:**
|
|
13
|
+
- Never swallow errors - let them bubble up
|
|
14
|
+
- Consistent default margins (shared constant)
|
|
15
|
+
- `raw` escape hatch for direct Prawn access
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# lib/pdf/renderer.rb
|
|
19
|
+
# frozen_string_literal: true
|
|
20
|
+
|
|
21
|
+
require "prawn"
|
|
22
|
+
require "prawn/table"
|
|
23
|
+
|
|
24
|
+
module Pdf
|
|
25
|
+
# Default margins used by both Renderer and Layout
|
|
26
|
+
DEFAULT_MARGINS = { top: 72, right: 36, bottom: 72, left: 36 }.freeze
|
|
27
|
+
|
|
28
|
+
class Renderer
|
|
29
|
+
attr_reader :pdf
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@pdf = nil
|
|
33
|
+
@footer_callback = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def setup(margins: {})
|
|
37
|
+
m = Pdf::DEFAULT_MARGINS.merge(margins)
|
|
38
|
+
@pdf = Prawn::Document.new(
|
|
39
|
+
margin: [m[:top], m[:right], m[:bottom], m[:left]]
|
|
40
|
+
)
|
|
41
|
+
setup_fonts
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finalize
|
|
46
|
+
apply_footer if @footer_callback
|
|
47
|
+
@pdf.render
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Primitives ---
|
|
51
|
+
|
|
52
|
+
def text(content, **options)
|
|
53
|
+
@pdf.text content.to_s, **options
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def text_box(content, at:, **options)
|
|
57
|
+
@pdf.text_box content.to_s, at: at, **options
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def move_down(amount)
|
|
61
|
+
@pdf.move_down amount
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stroke_horizontal_rule
|
|
65
|
+
@pdf.stroke_horizontal_rule
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def start_new_page
|
|
69
|
+
@pdf.start_new_page
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cursor
|
|
73
|
+
@pdf.cursor
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bounds
|
|
77
|
+
@pdf.bounds
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def bounding_box(point, **options, &block)
|
|
81
|
+
@pdf.bounding_box(point, **options, &block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def canvas(&block)
|
|
85
|
+
@pdf.canvas(&block)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def float(&block)
|
|
89
|
+
@pdf.float(&block)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Image embedding - raises if file not found (no silent failures)
|
|
93
|
+
def image(path, **options)
|
|
94
|
+
raise Pdf::FileNotFoundError, "Image not found: #{path}" unless File.exist?(path)
|
|
95
|
+
|
|
96
|
+
@pdf.image path, **options
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Image from string data (for generated images like QR codes)
|
|
100
|
+
def image_data(data, **options)
|
|
101
|
+
@pdf.image StringIO.new(data), **options
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fill_color(color)
|
|
105
|
+
@pdf.fill_color color
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def stroke_color(color)
|
|
109
|
+
@pdf.stroke_color color
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def line_width(width)
|
|
113
|
+
@pdf.line_width width
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fill_and_stroke_rounded_rectangle(point, width, height, radius)
|
|
117
|
+
@pdf.fill_and_stroke_rounded_rectangle point, width, height, radius
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def draw_text(content, at:, **options)
|
|
121
|
+
@pdf.draw_text content.to_s, at: at, **options
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def width_of(content, **options)
|
|
125
|
+
@pdf.width_of content.to_s, **options
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def height_of(content, **options)
|
|
129
|
+
@pdf.height_of content.to_s, **options
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def table(data, **options, &block)
|
|
133
|
+
@pdf.table(data, **options, &block)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def page_number
|
|
137
|
+
@pdf.page_number
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def page_count
|
|
141
|
+
@pdf.page_count
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def repeat(page_filter, options = {}, &block)
|
|
145
|
+
@pdf.repeat(page_filter, options, &block)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# --- Escape hatch for direct Prawn access ---
|
|
149
|
+
|
|
150
|
+
def raw(&block)
|
|
151
|
+
yield @pdf
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- Footer setup (deferred) ---
|
|
155
|
+
|
|
156
|
+
def setup_footer(&block)
|
|
157
|
+
@footer_callback = block
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def setup_fonts
|
|
163
|
+
# Use Helvetica by default (built into Prawn)
|
|
164
|
+
# Users can override in their app
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def apply_footer
|
|
168
|
+
@footer_callback&.call(@pdf)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Tests
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# test/pdf/renderer_test.rb
|
|
180
|
+
# frozen_string_literal: true
|
|
181
|
+
|
|
182
|
+
require "test_helper"
|
|
183
|
+
|
|
184
|
+
class RendererTest < Minitest::Test
|
|
185
|
+
def setup
|
|
186
|
+
@renderer = Pdf::Renderer.new.setup
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_setup_creates_prawn_document
|
|
190
|
+
assert_kind_of Prawn::Document, @renderer.pdf
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def test_setup_accepts_custom_margins
|
|
194
|
+
r = Pdf::Renderer.new.setup(margins: { top: 100 })
|
|
195
|
+
assert_kind_of Prawn::Document, r.pdf
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def test_uses_default_margins
|
|
199
|
+
expected = { top: 72, right: 36, bottom: 72, left: 36 }
|
|
200
|
+
assert_equal expected, Pdf::DEFAULT_MARGINS
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def test_finalize_returns_pdf_binary
|
|
204
|
+
result = @renderer.finalize
|
|
205
|
+
assert result.start_with?("%PDF")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_text_delegates_to_prawn
|
|
209
|
+
# Just verify it doesn't raise
|
|
210
|
+
@renderer.text("Hello", size: 12)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def test_image_raises_file_not_found_error
|
|
214
|
+
error = assert_raises(Pdf::FileNotFoundError) do
|
|
215
|
+
@renderer.image("/nonexistent/path.png")
|
|
216
|
+
end
|
|
217
|
+
assert_match(/Image not found/, error.message)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def test_raw_yields_prawn_document
|
|
221
|
+
yielded = nil
|
|
222
|
+
@renderer.raw { |pdf| yielded = pdf }
|
|
223
|
+
assert_equal @renderer.pdf, yielded
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_raw_allows_direct_prawn_operations
|
|
227
|
+
@renderer.raw { |pdf| pdf.stroke_circle [100, 100], 50 }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_setup_footer_stores_callback_for_finalize
|
|
231
|
+
called = false
|
|
232
|
+
@renderer.setup_footer { |_pdf| called = true }
|
|
233
|
+
@renderer.finalize
|
|
234
|
+
assert called
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|