25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
|
# File 'lib/seo/output/reporter.rb', line 25
def render_pdf(results, output_path:)
avg_score = results.sum(&:score) / [results.length, 1].max
total_issues = results.sum { |r| r.issues.length }
clean_count = results.count { |r| r.issues.empty? }
Prawn::Document.generate(output_path, page_size: "A4", margin: [36, 40, 36, 40]) do |pdf|
pdf.fill_color INDIGO
pdf.fill_rectangle [pdf.bounds.left, pdf.bounds.top + 36], pdf.bounds.width + 80, 70
pdf.fill_color WHITE
pdf.move_to [pdf.bounds.left, pdf.bounds.top + 20]
pdf.font_size(18) { pdf.text "SEO Report", style: :bold }
pdf.font_size(9) { pdf.text "Generated #{Time.now.strftime("%d %b %Y %H:%M")} · #{results.length} files scanned", color: "c7d2fe" }
pdf.move_down 20
pdf.fill_color "000000"
summary_data = [
["Average Score", "#{avg_score}/100", score_color(avg_score)],
["Total Issues", total_issues.to_s, total_issues.zero? ? GREEN : RED],
["Clean Files", "#{clean_count}/#{results.length}", clean_count == results.length ? GREEN : AMBER]
]
card_width = (pdf.bounds.width - 20) / 3.0
card_height = 52
summary_data.each_with_index do |(label, value, color), i|
x = pdf.bounds.left + i * (card_width + 10)
y = pdf.cursor
pdf.fill_color LIGHT
pdf.fill_rounded_rectangle [x, y], card_width, card_height, 4
pdf.fill_color "94a3b8"
pdf.font_size(7) { pdf.draw_text label.upcase, at: [x + 10, y - 14] }
pdf.fill_color color
pdf.font_size(20) { pdf.draw_text value, at: [x + 10, y - 38], style: :bold }
end
pdf.move_down card_height + 16
results.each do |result|
pdf.fill_color "000000"
card_start_y = pdf.cursor
pdf.fill_color LIGHT
pdf.fill_rounded_rectangle [pdf.bounds.left, card_start_y], pdf.bounds.width, 10, 4
pdf.fill_color INDIGO
pdf.font_size(8) { pdf.font("Courier") { pdf.text result.file, style: :bold } }
pdf.move_down 4
if result.issues.empty?
pdf.fill_color GREEN
pdf.font_size(9) { pdf.text "✓ No issues found" }
else
result.issues.each do |issue|
sev_color = issue[:severity] == :critical ? RED : AMBER
pdf.fill_color sev_color
pdf.font_size(7.5) do
pdf.text "[#{issue[:severity].to_s.upcase}] [#{issue[:check]}] #{issue[:message]}", indent_paragraphs: 8
end
if issue[:ai_risk] && issue[:ai_risk] != "none"
risk_color = { "high" => RED, "medium" => AMBER, "low" => GREEN }.fetch(issue[:ai_risk], SLATE)
pdf.fill_color risk_color
pdf.font_size(7) { pdf.text " AI Overview risk: #{issue[:ai_risk].upcase}", indent_paragraphs: 16 }
end
end
unless result.suggestions.empty?
pdf.move_down 3
pdf.fill_color SLATE
result.suggestions.each do |s|
pdf.font_size(7.5) { pdf.text "• #{s}", indent_paragraphs: 8 }
end
end
end
pdf.move_down 4
pdf.fill_color "94a3b8"
pdf.font_size(7) { pdf.text "Score: #{result.score}/100", align: :right, color: score_color(result.score) }
pdf.move_down 10
card_height_actual = card_start_y - pdf.cursor
pdf.fill_color LIGHT
pdf.fill_rounded_rectangle [pdf.bounds.left, card_start_y], pdf.bounds.width, card_height_actual, 4
pdf.move_down 8
pdf.start_new_page if pdf.cursor < 80
end
end
end
|