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,435 @@
|
|
|
1
|
+
# Phase 7: Entry Point & Extensibility
|
|
2
|
+
|
|
3
|
+
## Files
|
|
4
|
+
- `lib/pdf.rb`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Entry Point
|
|
9
|
+
|
|
10
|
+
Main entry point. Requires all files, provides component registry and error classes.
|
|
11
|
+
|
|
12
|
+
**Changes:**
|
|
13
|
+
- Added `FileNotFoundError` for explicit file errors
|
|
14
|
+
- Removed `PageNumber` component (handled by FooterEvaluator)
|
|
15
|
+
- Added `Date` and `Spacer` components
|
|
16
|
+
- Added `DynamicComponents` module
|
|
17
|
+
- Added `rqrcode` require
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# lib/pdf.rb
|
|
21
|
+
# frozen_string_literal: true
|
|
22
|
+
|
|
23
|
+
require "prawn"
|
|
24
|
+
require "prawn/table"
|
|
25
|
+
require "rqrcode"
|
|
26
|
+
|
|
27
|
+
require_relative "pdf/version"
|
|
28
|
+
require_relative "pdf/blueprint"
|
|
29
|
+
require_relative "pdf/resolver"
|
|
30
|
+
require_relative "pdf/component"
|
|
31
|
+
require_relative "pdf/dynamic_components"
|
|
32
|
+
require_relative "pdf/renderer"
|
|
33
|
+
|
|
34
|
+
require_relative "pdf/builders/content_builder"
|
|
35
|
+
require_relative "pdf/builders/header_builder"
|
|
36
|
+
require_relative "pdf/builders/footer_builder"
|
|
37
|
+
|
|
38
|
+
require_relative "pdf/components/title"
|
|
39
|
+
require_relative "pdf/components/subtitle"
|
|
40
|
+
require_relative "pdf/components/heading"
|
|
41
|
+
require_relative "pdf/components/paragraph"
|
|
42
|
+
require_relative "pdf/components/span"
|
|
43
|
+
require_relative "pdf/components/date"
|
|
44
|
+
require_relative "pdf/components/hr"
|
|
45
|
+
require_relative "pdf/components/spacer"
|
|
46
|
+
require_relative "pdf/components/alert"
|
|
47
|
+
require_relative "pdf/components/table"
|
|
48
|
+
require_relative "pdf/components/logo"
|
|
49
|
+
require_relative "pdf/components/qr_code"
|
|
50
|
+
require_relative "pdf/components/context"
|
|
51
|
+
|
|
52
|
+
require_relative "pdf/content_evaluator"
|
|
53
|
+
require_relative "pdf/header_evaluator"
|
|
54
|
+
require_relative "pdf/footer_evaluator"
|
|
55
|
+
|
|
56
|
+
require_relative "pdf/view"
|
|
57
|
+
require_relative "pdf/layout"
|
|
58
|
+
|
|
59
|
+
module Pdf
|
|
60
|
+
class Error < StandardError; end
|
|
61
|
+
class FileNotFoundError < Error; end
|
|
62
|
+
|
|
63
|
+
# Component registry (PageNumber is handled by FooterEvaluator, not here)
|
|
64
|
+
@components = {
|
|
65
|
+
title: Components::Title,
|
|
66
|
+
subtitle: Components::Subtitle,
|
|
67
|
+
heading: Components::Heading,
|
|
68
|
+
paragraph: Components::Paragraph,
|
|
69
|
+
span: Components::Span,
|
|
70
|
+
date: Components::Date,
|
|
71
|
+
hr: Components::Hr,
|
|
72
|
+
spacer: Components::Spacer,
|
|
73
|
+
alert: Components::Alert,
|
|
74
|
+
table: Components::Table,
|
|
75
|
+
logo: Components::Logo,
|
|
76
|
+
qr_code: Components::QrCode,
|
|
77
|
+
context: Components::Context
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# Register a custom component
|
|
82
|
+
#
|
|
83
|
+
# @param name [Symbol] Component name for DSL
|
|
84
|
+
# @param klass [Class] Component class (must inherit from Pdf::Component)
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# class MyBadge < Pdf::Component
|
|
88
|
+
# def render(text, color: "blue")
|
|
89
|
+
# # ...
|
|
90
|
+
# end
|
|
91
|
+
# end
|
|
92
|
+
#
|
|
93
|
+
# Pdf.register_component(:badge, MyBadge)
|
|
94
|
+
#
|
|
95
|
+
# class MyReport < Pdf::View
|
|
96
|
+
# badge "NEW", color: "red"
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
def register_component(name, klass)
|
|
100
|
+
unless klass < Component
|
|
101
|
+
raise ArgumentError, "#{klass} must inherit from Pdf::Component"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@components[name.to_sym] = klass
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if component is registered
|
|
108
|
+
#
|
|
109
|
+
# @param name [Symbol] Component name
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
#
|
|
112
|
+
def component_registered?(name)
|
|
113
|
+
@components.key?(name.to_sym)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get component class
|
|
117
|
+
#
|
|
118
|
+
# @param name [Symbol] Component name
|
|
119
|
+
# @return [Class]
|
|
120
|
+
# @raise [Error] if not found
|
|
121
|
+
#
|
|
122
|
+
def component(name)
|
|
123
|
+
@components.fetch(name.to_sym) do
|
|
124
|
+
raise Error, "Unknown component: #{name}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# List registered components
|
|
129
|
+
#
|
|
130
|
+
# @return [Array<Symbol>]
|
|
131
|
+
#
|
|
132
|
+
def components
|
|
133
|
+
@components.keys
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Usage Examples
|
|
142
|
+
|
|
143
|
+
### Basic View
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class InvoiceReport < Pdf::View
|
|
147
|
+
title "INVOICE"
|
|
148
|
+
date :invoice_date # Uses real Date component now
|
|
149
|
+
|
|
150
|
+
paragraph :description
|
|
151
|
+
|
|
152
|
+
# Conditional rendering
|
|
153
|
+
render_if(:has_discount) do
|
|
154
|
+
alert :discount_info
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
section "Items" do
|
|
158
|
+
table :line_items, columns: [:name, :qty, :price]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
section "Summary" do
|
|
162
|
+
paragraph :total_line
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def invoice_date
|
|
166
|
+
data[:date] # Date component handles formatting
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def description
|
|
170
|
+
"Invoice for #{data[:customer_name]}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def has_discount
|
|
174
|
+
data[:discount].present?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def discount_info
|
|
178
|
+
{ title: "Discount Applied!", description: data[:discount], color: "green" }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def line_items
|
|
182
|
+
data[:items]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def total_line
|
|
186
|
+
"Total: $#{data[:total]}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Generate
|
|
191
|
+
pdf = InvoiceReport.new(
|
|
192
|
+
date: Date.current,
|
|
193
|
+
customer_name: "Acme Corp",
|
|
194
|
+
items: [
|
|
195
|
+
{ name: "Widget", qty: 5, price: "$10.00" },
|
|
196
|
+
{ name: "Gadget", qty: 2, price: "$25.00" }
|
|
197
|
+
],
|
|
198
|
+
total: "100.00"
|
|
199
|
+
).to_pdf
|
|
200
|
+
|
|
201
|
+
File.binwrite("invoice.pdf", pdf)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### With Layout
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class CompanyLayout < Pdf::Layout
|
|
208
|
+
header do
|
|
209
|
+
logo "logo.png", width: 100
|
|
210
|
+
qr_code data: :qr_url, size: 60, position: :right # Generates QR from data
|
|
211
|
+
context :header_lines
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
footer do
|
|
215
|
+
text :company_name, size: 8
|
|
216
|
+
page_number position: :right
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
margins top: 80, bottom: 60
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
class Report < Pdf::View
|
|
223
|
+
layout CompanyLayout
|
|
224
|
+
|
|
225
|
+
title :report_title
|
|
226
|
+
alert :summary
|
|
227
|
+
|
|
228
|
+
# Raw Prawn access when needed
|
|
229
|
+
raw do |pdf|
|
|
230
|
+
pdf.stroke_color "CCCCCC"
|
|
231
|
+
pdf.dash(2)
|
|
232
|
+
pdf.stroke_horizontal_line 0, pdf.bounds.width
|
|
233
|
+
pdf.undash
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def report_title = data[:title]
|
|
237
|
+
def summary = data[:summary]
|
|
238
|
+
def header_lines = ["Company: #{data[:company]}", "Date: #{Date.current}"]
|
|
239
|
+
def company_name = data[:company]
|
|
240
|
+
def qr_url = "https://example.com/reports/#{data[:id]}"
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### View Inheritance
|
|
245
|
+
|
|
246
|
+
Child views inherit parent's elements and can add more.
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
class BaseReport < Pdf::View
|
|
250
|
+
title "Company Report"
|
|
251
|
+
hr
|
|
252
|
+
spacer amount: 10
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
class SalesReport < BaseReport
|
|
256
|
+
# Inherits: title "Company Report", hr, spacer
|
|
257
|
+
subtitle "Sales Department"
|
|
258
|
+
table :sales_data, columns: [:product, :revenue]
|
|
259
|
+
|
|
260
|
+
def sales_data = data[:sales]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
class FinanceReport < BaseReport
|
|
264
|
+
# Inherits: title "Company Report", hr, spacer
|
|
265
|
+
subtitle "Finance Department"
|
|
266
|
+
render_unless(:is_draft) { table :budget_data, columns: [:category, :amount] }
|
|
267
|
+
|
|
268
|
+
def budget_data = data[:budget]
|
|
269
|
+
def is_draft = data[:draft]
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Custom Component
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
class Badge < Pdf::Component
|
|
277
|
+
COLORS = {
|
|
278
|
+
"success" => "4CAF50",
|
|
279
|
+
"warning" => "FF9800",
|
|
280
|
+
"error" => "F44336"
|
|
281
|
+
}.freeze
|
|
282
|
+
|
|
283
|
+
def render(text, type: "success", **_options)
|
|
284
|
+
color = COLORS[type.to_s] || COLORS["success"]
|
|
285
|
+
|
|
286
|
+
bounding_box([0, cursor], width: 60, height: 20) do
|
|
287
|
+
@pdf.fill_color color
|
|
288
|
+
@pdf.fill_rounded_rectangle [0, 20], 60, 20, 4
|
|
289
|
+
@pdf.fill_color "FFFFFF"
|
|
290
|
+
@pdf.text_box text.to_s, at: [5, 16], width: 50, size: 10, align: :center
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
@pdf.fill_color "000000"
|
|
294
|
+
move_down 25
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
Pdf.register_component(:badge, Badge)
|
|
299
|
+
|
|
300
|
+
class StatusReport < Pdf::View
|
|
301
|
+
title "Status Report"
|
|
302
|
+
badge "LIVE", type: "success"
|
|
303
|
+
paragraph "All systems operational."
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Specs
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
# spec/pdf_spec.rb
|
|
313
|
+
RSpec.describe Pdf do
|
|
314
|
+
describe ".register_component" do
|
|
315
|
+
let(:custom_component) do
|
|
316
|
+
Class.new(Pdf::Component) do
|
|
317
|
+
def render(text) = @pdf.text(text)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
after { described_class.instance_variable_get(:@components).delete(:custom) }
|
|
322
|
+
|
|
323
|
+
it "registers component" do
|
|
324
|
+
described_class.register_component(:custom, custom_component)
|
|
325
|
+
expect(described_class.component_registered?(:custom)).to be true
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "rejects non-Component classes" do
|
|
329
|
+
expect {
|
|
330
|
+
described_class.register_component(:bad, String)
|
|
331
|
+
}.to raise_error(ArgumentError)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
describe ".component" do
|
|
336
|
+
it "returns built-in components" do
|
|
337
|
+
expect(described_class.component(:title)).to eq(Pdf::Components::Title)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it "returns date component" do
|
|
341
|
+
expect(described_class.component(:date)).to eq(Pdf::Components::Date)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
it "returns spacer component" do
|
|
345
|
+
expect(described_class.component(:spacer)).to eq(Pdf::Components::Spacer)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it "raises for unknown" do
|
|
349
|
+
expect { described_class.component(:unknown) }.to raise_error(Pdf::Error)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
describe ".components" do
|
|
354
|
+
it "lists all registered" do
|
|
355
|
+
expect(described_class.components).to include(:title, :table, :alert, :date, :spacer)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it "does not include page_number (handled by evaluator)" do
|
|
359
|
+
expect(described_class.components).not_to include(:page_number)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
describe "Pdf::FileNotFoundError" do
|
|
364
|
+
it "is a subclass of Pdf::Error" do
|
|
365
|
+
expect(Pdf::FileNotFoundError.superclass).to eq(Pdf::Error)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# spec/integration_spec.rb
|
|
371
|
+
RSpec.describe "Integration" do
|
|
372
|
+
let(:layout_class) do
|
|
373
|
+
Class.new(Pdf::Layout) do
|
|
374
|
+
header do
|
|
375
|
+
context :header_lines
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
footer do
|
|
379
|
+
text :footer_text
|
|
380
|
+
page_number
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
margins top: 70, bottom: 60
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
let(:view_class) do
|
|
388
|
+
lc = layout_class
|
|
389
|
+
Class.new(Pdf::View) do
|
|
390
|
+
layout lc
|
|
391
|
+
|
|
392
|
+
title "TEST REPORT"
|
|
393
|
+
date :report_date
|
|
394
|
+
alert :status
|
|
395
|
+
|
|
396
|
+
render_if(:show_details) do
|
|
397
|
+
section "Data" do
|
|
398
|
+
table :items, columns: [:name, :value]
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def report_date = Date.new(2024, 1, 15)
|
|
403
|
+
def status = { title: "OK", color: "green" }
|
|
404
|
+
def show_details = data[:show]
|
|
405
|
+
def items = data[:items]
|
|
406
|
+
def header_lines = ["Test Header"]
|
|
407
|
+
def footer_text = "Confidential"
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it "generates valid PDF" do
|
|
412
|
+
pdf = view_class.new(items: [{ name: "A", value: "1" }], show: true).to_pdf
|
|
413
|
+
expect(pdf).to start_with("%PDF")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
it "writes to file" do
|
|
417
|
+
path = "tmp/test.pdf"
|
|
418
|
+
view_class.new(items: [], show: false).to_file(path)
|
|
419
|
+
expect(File.exist?(path)).to be true
|
|
420
|
+
ensure
|
|
421
|
+
FileUtils.rm_f(path)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
describe "view inheritance" do
|
|
425
|
+
let(:parent) { Class.new(Pdf::View) { title "Parent" } }
|
|
426
|
+
let(:child) { Class.new(parent) { subtitle "Child" } }
|
|
427
|
+
|
|
428
|
+
it "child generates PDF with both elements" do
|
|
429
|
+
pdf = child.new.to_pdf
|
|
430
|
+
expect(pdf).to start_with("%PDF")
|
|
431
|
+
expect(pdf).to include("Parent")
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
```
|