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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Builders
5
+ class FooterBuilder
6
+ include DynamicComponents
7
+
8
+ attr_reader :blueprint
9
+
10
+ def initialize
11
+ @blueprint = Blueprint.new
12
+ end
13
+
14
+ def text(source, **options)
15
+ @blueprint.add(:footer_text, source, **options)
16
+ end
17
+
18
+ # Page number is handled by FooterEvaluator, not as a component
19
+ def page_number(**options)
20
+ @blueprint.add(:page_number, **options)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Builders
5
+ class HeaderBuilder
6
+ include DynamicComponents
7
+
8
+ attr_reader :blueprint
9
+
10
+ def initialize
11
+ @blueprint = Blueprint.new
12
+ end
13
+
14
+ def logo(path, **options)
15
+ @blueprint.add(:logo, path, **options)
16
+ end
17
+
18
+ def qr_code(**options)
19
+ @blueprint.add(:qr_code, **options)
20
+ end
21
+
22
+ def context(source, **options)
23
+ @blueprint.add(:context, source, **options)
24
+ end
25
+
26
+ def text(value, **options)
27
+ @blueprint.add(:header_text, value, **options)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class Component
5
+ attr_reader :pdf
6
+
7
+ def initialize(pdf)
8
+ @pdf = pdf
9
+ end
10
+
11
+ def render(...)
12
+ raise NotImplementedError, "#{self.class}#render not implemented"
13
+ end
14
+
15
+ private
16
+
17
+ # Primitive helpers (delegate to pdf)
18
+
19
+ def text(content, **options)
20
+ @pdf.text content.to_s, **options
21
+ end
22
+
23
+ def move_down(amount)
24
+ @pdf.move_down amount
25
+ end
26
+
27
+ def stroke_horizontal_rule
28
+ @pdf.stroke_horizontal_rule
29
+ end
30
+
31
+ def bounding_box(point, **options, &block)
32
+ @pdf.bounding_box(point, **options, &block)
33
+ end
34
+
35
+ def cursor
36
+ @pdf.cursor
37
+ end
38
+
39
+ def bounds
40
+ @pdf.bounds
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Alert < Component
6
+ COLORS = {
7
+ "green" => { bg: "C8E6C9", text: "2E7D32", border: "4CAF50" },
8
+ "orange" => { bg: "FFE0B2", text: "E65100", border: "FF9800" },
9
+ "red" => { bg: "FFCDD2", text: "C62828", border: "F44336" },
10
+ "blue" => { bg: "E1F5FE", text: "0277BD", border: "2196F3" }
11
+ }.freeze
12
+
13
+ def render(title:, description: nil, color: "blue", **_options)
14
+ scheme = COLORS[color.to_s] || COLORS["blue"]
15
+
16
+ title_h = 20
17
+ desc_h = description ? (@pdf.height_of(description.to_s, width: bounds.width - 20, size: 10) + 10) : 0
18
+ total_h = title_h + desc_h + 6
19
+
20
+ bounding_box([bounds.left, cursor], width: bounds.width, height: total_h) do
21
+ @pdf.stroke_color scheme[:border]
22
+ @pdf.line_width 0.5
23
+ @pdf.fill_color scheme[:bg]
24
+ @pdf.fill_and_stroke_rounded_rectangle [bounds.left, bounds.top], bounds.width, bounds.height, 5
25
+
26
+ @pdf.fill_color scheme[:text]
27
+
28
+ y = bounds.top - 10
29
+ @pdf.text_box title.to_s, at: [10, y], width: bounds.width - 20, size: 11, style: :bold
30
+
31
+ if description
32
+ y -= 18
33
+ @pdf.text_box description.to_s, at: [10, y], width: bounds.width - 20, size: 10
34
+ end
35
+ end
36
+
37
+ @pdf.fill_color "000000"
38
+ move_down 15
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Context < Component
6
+ def render(lines, label: "CONTEXT", **_options)
7
+ raise ArgumentError, "Context lines cannot be nil" if lines.nil?
8
+ return if lines.empty?
9
+
10
+ text label, size: 12, style: :bold
11
+ lines.each { |line| text line.to_s, size: 10 }
12
+ move_down 20
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Date < Component
6
+ DEFAULT_FORMAT = "%B %d, %Y"
7
+
8
+ def render(value, format: DEFAULT_FORMAT, size: 10, align: :right, **options)
9
+ formatted = format_date(value, format)
10
+ text formatted, size: size, align: align, **options
11
+ move_down 5
12
+ end
13
+
14
+ private
15
+
16
+ def format_date(value, format)
17
+ case value
18
+ when ::Date, ::Time, ::DateTime
19
+ value.strftime(format)
20
+ when String
21
+ value
22
+ else
23
+ value.to_s
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Heading < Component
6
+ def render(content, size: 14, **options)
7
+ text content, size: size, style: :bold, **options
8
+ move_down 8
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Hr < Component
6
+ def render(**_options)
7
+ stroke_horizontal_rule
8
+ move_down 10
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Logo < Component
6
+ def render(path, width: 120, height: nil, **_options)
7
+ raise Pdf::FileNotFoundError, "Logo not found: #{path}" unless File.exist?(path)
8
+
9
+ opts = { width: width }
10
+ opts[:height] = height if height
11
+ @pdf.image path, **opts
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Paragraph < Component
6
+ def render(content, size: 10, leading: 3, **options)
7
+ text content, size: size, leading: leading, **options
8
+ move_down 10
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rqrcode"
4
+
5
+ module Pdf
6
+ module Components
7
+ class QrCode < Component
8
+ def render(data:, size: 80, position: :right, **_options)
9
+ png_data = generate_qr_png(data, size)
10
+
11
+ if position == :right
12
+ @pdf.float do
13
+ bounding_box([bounds.width - size, cursor], width: size) do
14
+ @pdf.image StringIO.new(png_data), width: size, height: size
15
+ end
16
+ end
17
+ else
18
+ @pdf.image StringIO.new(png_data), width: size, height: size
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def generate_qr_png(data, size)
25
+ qrcode = RQRCode::QRCode.new(data.to_s)
26
+ png = qrcode.as_png(
27
+ bit_depth: 1,
28
+ border_modules: 2,
29
+ color_mode: ChunkyPNG::COLOR_GRAYSCALE,
30
+ color: "black",
31
+ fill: "white",
32
+ size: size * 4 # Higher resolution for quality
33
+ )
34
+ png.to_s
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Spacer < Component
6
+ def render(amount: 20, **_options)
7
+ move_down amount
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Span < Component
6
+ def render(content, size: 10, **options)
7
+ text content, size: size, **options
8
+ move_down 3
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Subtitle < Component
6
+ def render(content, size: 16, **options)
7
+ text content, size: size, **options
8
+ move_down 5
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Table < Component
6
+ def render(headers:, rows:, widths: nil, **_options)
7
+ return if rows.empty? && headers.empty?
8
+
9
+ data = [headers] + rows
10
+
11
+ opts = {
12
+ header: true,
13
+ width: bounds.width,
14
+ cell_style: { borders: [:top, :bottom, :left, :right], padding: [4, 6], size: 8 }
15
+ }
16
+
17
+ # Only set column_widths if explicit numeric values (not percentages)
18
+ # For percentages, let prawn auto-distribute based on content
19
+ if widths && !widths.any? { |w| w.is_a?(String) && w.end_with?("%") }
20
+ opts[:column_widths] = calculate_widths(widths)
21
+ end
22
+
23
+ @pdf.table(data, **opts) do
24
+ row(0).background_color = "333333"
25
+ row(0).text_color = "FFFFFF"
26
+ row(0).font_style = :bold
27
+
28
+ (1..row_length - 1).each do |i|
29
+ row(i).background_color = i.odd? ? "F5F5F5" : "FFFFFF"
30
+ end
31
+ end
32
+
33
+ move_down 15
34
+ end
35
+
36
+ private
37
+
38
+ def calculate_widths(widths)
39
+ widths.map do |w|
40
+ case w
41
+ when Numeric then w.to_f
42
+ else raise ArgumentError, "Invalid width: #{w}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module Components
5
+ class Title < Component
6
+ def render(content, size: 20, **options)
7
+ text content, size: size, style: :bold, **options
8
+ move_down 10
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class ContentEvaluator
5
+ def initialize(context, renderer)
6
+ @context = context
7
+ @renderer = renderer
8
+ @resolver = Resolver.new(context)
9
+ end
10
+
11
+ def evaluate(blueprint)
12
+ blueprint.elements.each { |el| evaluate_element(el) }
13
+ end
14
+
15
+ private
16
+
17
+ def evaluate_element(element)
18
+ type = element[:type]
19
+ args = element[:args]
20
+ options = element[:options]
21
+
22
+ case type
23
+ when :title, :subtitle, :heading, :paragraph, :span
24
+ render_text_component(type, args.first, options)
25
+
26
+ when :date
27
+ render_date(args.first, options)
28
+
29
+ when :hr
30
+ component(:hr).render(**options)
31
+
32
+ when :spacer
33
+ component(:spacer).render(**options)
34
+
35
+ when :alert
36
+ render_alert(args.first, options)
37
+
38
+ when :table
39
+ render_table(args.first, options)
40
+
41
+ when :section
42
+ render_section(args.first, options)
43
+
44
+ when :each
45
+ render_each(args.first, options)
46
+
47
+ when :partial
48
+ @context.send(args.first, @renderer)
49
+
50
+ when :page_break
51
+ @renderer.start_new_page
52
+
53
+ when :page_break_if
54
+ threshold = options[:threshold]
55
+ @renderer.start_new_page if @renderer.cursor < (@renderer.bounds.height * threshold)
56
+
57
+ when :render_if
58
+ render_conditional(args.first, options[:nested], truthy: true)
59
+
60
+ when :render_unless
61
+ render_conditional(args.first, options[:nested], truthy: false)
62
+
63
+ when :raw
64
+ # Escape hatch for direct Prawn access
65
+ block = options[:block]
66
+ @renderer.raw(&block) if block
67
+
68
+ else
69
+ # Try registered component
70
+ render_custom_component(type, args, options)
71
+ end
72
+ end
73
+
74
+ def render_text_component(type, source, options)
75
+ content = @resolver.resolve(source)
76
+ component(type).render(content, **options)
77
+ end
78
+
79
+ def render_date(source, options)
80
+ value = @resolver.resolve(source)
81
+ component(:date).render(value, **options)
82
+ end
83
+
84
+ def render_alert(source, options)
85
+ # Handle inline alert (title/description passed as options)
86
+ if source.nil? && (options[:title] || options[:description])
87
+ component(:alert).render(
88
+ title: options[:title],
89
+ description: options[:description],
90
+ color: options[:color] || "blue"
91
+ )
92
+ return
93
+ end
94
+
95
+ data = @resolver.resolve(source)
96
+
97
+ if data.is_a?(Hash)
98
+ component(:alert).render(
99
+ title: data[:title] || data["title"],
100
+ description: data[:description] || data["description"] || data[:subtitle] || data["subtitle"],
101
+ color: data[:color] || data["color"] || options[:color] || "blue"
102
+ )
103
+ else
104
+ component(:alert).render(title: data.to_s, color: options[:color] || "blue")
105
+ end
106
+ end
107
+
108
+ def render_table(source, options)
109
+ data = @resolver.resolve(source)
110
+ columns = options[:columns]
111
+ widths = options[:widths]
112
+
113
+ headers, rows = extract_table_data(data, columns, options)
114
+ component(:table).render(headers: headers, rows: rows, widths: widths)
115
+ end
116
+
117
+ def extract_table_data(data, columns, options)
118
+ if data.is_a?(Array) && data.first.is_a?(Hash) && columns
119
+ headers = columns.map { |c| c.is_a?(Hash) ? c[:label] : c.to_s.capitalize }
120
+ rows = data.map do |row|
121
+ columns.map do |col|
122
+ key = col.is_a?(Hash) ? col[:key] : col
123
+ row[key] || row[key.to_s] || "-"
124
+ end
125
+ end
126
+ [headers, rows]
127
+
128
+ elsif data.is_a?(Hash) && data[:headers] && data[:rows]
129
+ [data[:headers], data[:rows]]
130
+
131
+ elsif data.is_a?(Array) && data.first.is_a?(Array)
132
+ if options[:headers]
133
+ [options[:headers], data]
134
+ else
135
+ [data.first, data[1..]]
136
+ end
137
+
138
+ else
139
+ [["Key", "Value"], data.to_a]
140
+ end
141
+ end
142
+
143
+ def render_section(title, options)
144
+ component(:heading).render(title, size: 12)
145
+ evaluate(options[:nested]) if options[:nested]
146
+ end
147
+
148
+ def render_each(source, options)
149
+ collection = @resolver.resolve(source)
150
+ block = options[:block]
151
+ return if collection.nil? || collection.empty?
152
+
153
+ collection.each do |item|
154
+ item_context = EachItemContext.new(@context, item)
155
+ sub_evaluator = self.class.new(item_context, @renderer)
156
+
157
+ builder = Builders::ContentBuilder.new
158
+ builder.instance_exec(item, &block)
159
+ sub_evaluator.evaluate(builder.blueprint)
160
+ end
161
+ end
162
+
163
+ def render_conditional(condition, nested_blueprint, truthy:)
164
+ return unless nested_blueprint
165
+
166
+ condition_value = @resolver.resolve(condition)
167
+ should_render = truthy ? condition_value : !condition_value
168
+
169
+ evaluate(nested_blueprint) if should_render
170
+ end
171
+
172
+ def render_custom_component(type, args, options)
173
+ return unless Pdf.component_registered?(type)
174
+
175
+ comp = component(type)
176
+ if args.empty?
177
+ comp.render(**options)
178
+ else
179
+ resolved_args = args.map { |a| @resolver.resolve(a) }
180
+ comp.render(*resolved_args, **options)
181
+ end
182
+ end
183
+
184
+ def component(type)
185
+ Pdf.component(type).new(@renderer.pdf)
186
+ end
187
+ end
188
+
189
+ # Context wrapper for each iteration
190
+ class EachItemContext
191
+ def initialize(parent, item)
192
+ @parent = parent
193
+ @item = item
194
+ end
195
+
196
+ def data
197
+ @parent.data
198
+ end
199
+
200
+ attr_reader :item
201
+
202
+ def method_missing(method, *args, &block)
203
+ if @item.respond_to?(method)
204
+ @item.send(method, *args, &block)
205
+ elsif @item.is_a?(Hash) && (@item.key?(method) || @item.key?(method.to_s))
206
+ @item[method] || @item[method.to_s]
207
+ else
208
+ @parent.send(method, *args, &block)
209
+ end
210
+ end
211
+
212
+ def respond_to_missing?(method, include_private = false)
213
+ @item.respond_to?(method) ||
214
+ (@item.is_a?(Hash) && (@item.key?(method) || @item.key?(method.to_s))) ||
215
+ @parent.respond_to?(method, include_private)
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ module DynamicComponents
5
+ def method_missing(name, *args, **options, &block)
6
+ if Pdf.component_registered?(name)
7
+ @blueprint.add(name, *args, **options, &block)
8
+ else
9
+ super
10
+ end
11
+ end
12
+
13
+ def respond_to_missing?(name, include_private = false)
14
+ Pdf.component_registered?(name) || super
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class FooterEvaluator
5
+ def initialize(context, renderer)
6
+ @context = context
7
+ @renderer = renderer
8
+ @resolver = Resolver.new(context)
9
+ end
10
+
11
+ def setup(blueprint)
12
+ texts = []
13
+ page_number_opts = nil
14
+
15
+ blueprint.elements.each do |el|
16
+ case el[:type]
17
+ when :footer_text
18
+ content = @resolver.resolve(el[:args].first)
19
+ texts << { text: content, size: el[:options][:size] || 8 }
20
+
21
+ when :page_number
22
+ page_number_opts = el[:options]
23
+ end
24
+ end
25
+
26
+ @renderer.setup_footer do |pdf|
27
+ render_footer_content(pdf, texts, page_number_opts)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def render_footer_content(pdf, texts, page_number_opts)
34
+ footer_y = 24
35
+
36
+ # Text lines (left-aligned)
37
+ unless texts.empty?
38
+ pdf.repeat(:all) do
39
+ pdf.canvas do
40
+ y = footer_y
41
+ texts.each do |t|
42
+ pdf.draw_text t[:text].to_s, at: [36, y], size: t[:size]
43
+ y -= 12
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ # Page numbers (right-aligned)
50
+ return unless page_number_opts
51
+
52
+ position = page_number_opts[:position] || :right
53
+ size = page_number_opts[:size] || 8
54
+ fmt = page_number_opts[:format] || "Page %<page>d / %<total>d"
55
+
56
+ pdf.repeat(:all, dynamic: true) do
57
+ pdf.canvas do
58
+ page_str = format(fmt, page: pdf.page_number, total: pdf.page_count)
59
+ text_w = pdf.width_of(page_str, size: size)
60
+ x = position == :right ? pdf.bounds.absolute_right - 36 - text_w : 36
61
+ pdf.draw_text page_str, at: [x, footer_y], size: size
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end