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