"""
PDF report generation module with brand styling.
"""
from datetime import datetime
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
from collections import defaultdict
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, Image
)
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_RIGHT
from .charts import (
BRAND, SCORE_COLORS, UI, CATEGORY_NAMES_SV,
create_gauge_chart, create_overview_chart, create_issues_chart,
create_savings_chart, create_score_histogram, create_core_web_vitals_chart
)
from .analyzer import format_bytes
# Audit groups for categorization
AUDIT_GROUPS = {
"images": {
"title": "Bildoptimering",
"audits": ["modern-image-formats", "uses-responsive-images", "unsized-images",
"offscreen-images", "uses-optimized-images", "lcp-lazy-loaded",
"image-size-responsive"],
"onetime": True,
"description": "Bildrelaterade problem – åtgärdas med optimeringsverktyg och temaändringar."
},
"css_js": {
"title": "CSS & JavaScript",
"audits": ["unused-css-rules", "unused-javascript", "unminified-css",
"unminified-javascript", "render-blocking-resources", "legacy-javascript",
"duplicated-javascript", "bootup-time", "mainthread-work-breakdown"],
"onetime": True,
"description": "Oanvänd och blockerande kod – optimeras på temanivå med cachingplugins."
},
"caching": {
"title": "Caching & Leverans",
"audits": ["uses-long-cache-ttl", "uses-text-compression", "uses-http2",
"server-response-time", "total-byte-weight"],
"onetime": True,
"description": "Server- och cachingkonfiguration – kräver serverinställningar och CDN."
},
"accessibility": {
"title": "Tillgänglighetsproblem",
"audits": ["color-contrast", "link-name", "button-name", "image-alt",
"meta-viewport", "bypass", "html-has-lang", "document-title",
"heading-order", "label", "aria-"],
"onetime": False,
"description": "Tillgänglighetsproblem som hindrar användare med funktionsnedsättning."
},
"seo": {
"title": "SEO-problem",
"audits": ["meta-description", "link-text", "crawlable-anchors",
"robots-txt", "canonical", "hreflang", "structured-data"],
"onetime": False,
"description": "Sökmotoroptimeringsproblem som påverkar ranking och synlighet."
},
"errors": {
"title": "Fel & Varningar",
"audits": ["errors-in-console", "deprecations", "inspector-issues"],
"onetime": True,
"description": "JavaScript-fel och föråldrade API:er som indikerar teknisk skuld."
},
"layout": {
"title": "Layout & CLS",
"audits": ["cumulative-layout-shift", "dom-size", "largest-contentful-paint-element",
"layout-shift-elements", "long-tasks"],
"onetime": False,
"description": "Layout-stabilitet och renderingsprestanda – kritiskt för UX."
}
}
def get_styles():
"""Get PDF styles with brand colors."""
styles = getSampleStyleSheet()
brand_primary = colors.HexColor(BRAND["primary"])
brand_dark = colors.HexColor(BRAND["primary_dark"])
text_color = colors.HexColor(UI["text"])
muted_color = colors.HexColor(UI["muted"])
return {
"title": ParagraphStyle(
"Title", parent=styles["Heading1"], fontSize=28, spaceAfter=15,
alignment=TA_CENTER, textColor=brand_dark
),
"h1": ParagraphStyle(
"H1", parent=styles["Heading1"], fontSize=16, spaceBefore=12,
spaceAfter=8, textColor=brand_dark
),
"h2": ParagraphStyle(
"H2", parent=styles["Heading2"], fontSize=12, spaceBefore=10,
spaceAfter=5, textColor=text_color
),
"h3": ParagraphStyle(
"H3", parent=styles["Heading3"], fontSize=10, spaceBefore=6,
spaceAfter=3, textColor=text_color
),
"body": ParagraphStyle(
"Body", parent=styles["Normal"], fontSize=9, spaceAfter=5,
alignment=TA_JUSTIFY, leading=12, textColor=text_color
),
"body_bold": ParagraphStyle(
"BodyBold", parent=styles["Normal"], fontSize=9, spaceAfter=5,
alignment=TA_JUSTIFY, leading=12, fontName="Helvetica-Bold",
textColor=text_color
),
"small": ParagraphStyle(
"Small", parent=styles["Normal"], fontSize=8,
textColor=muted_color, leading=10, spaceAfter=3
),
"alert": ParagraphStyle(
"Alert", parent=styles["Normal"], fontSize=10,
textColor=colors.HexColor(SCORE_COLORS["poor"]), fontName="Helvetica-Bold"
),
"warning": ParagraphStyle(
"Warning", parent=styles["Normal"], fontSize=10,
textColor=colors.HexColor(SCORE_COLORS["needs_work"]), fontName="Helvetica-Bold"
),
"footer": ParagraphStyle(
"Footer", parent=styles["Normal"], fontSize=8,
textColor=muted_color, alignment=TA_RIGHT
),
}
def generate_pdf(
output_path: Path,
data: dict,
all_audits: dict,
resource_issues: dict,
element_issues: dict,
page_count: int,
customer_name: str = "",
site_url: str = "",
logo_path: Path = None,
consultant_name: str = "",
category_names: dict = None
):
"""Generate comprehensive PDF report with branding."""
if category_names is None:
category_names = CATEGORY_NAMES_SV
site_display = site_url.replace("https://", "").replace("http://", "").rstrip("/")
doc = SimpleDocTemplate(
str(output_path), pagesize=A4,
rightMargin=1.8*cm, leftMargin=1.8*cm, topMargin=1.8*cm, bottomMargin=2*cm,
)
s = get_styles()
story = []
brand_primary = colors.HexColor(BRAND["primary"])
brand_dark = colors.HexColor(BRAND["primary_dark"])
border_color = colors.HexColor(UI["border"])
light_bg = colors.HexColor(UI["light_bg"])
# ===== TITLE PAGE =====
story.append(Spacer(1, 1*cm))
# Logo
if logo_path and logo_path.exists():
try:
story.append(Image(str(logo_path), width=3*cm, height=3*cm))
story.append(Spacer(1, 0.5*cm))
except Exception:
pass
story.append(Paragraph("Webbplatsanalys", s["title"]))
story.append(Paragraph(site_display, ParagraphStyle(
"Sub", parent=s["title"], fontSize=20, textColor=colors.HexColor(UI["muted"])
)))
if customer_name:
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph(f"Kund: {customer_name}", ParagraphStyle(
"Customer", parent=s["body"], fontSize=11, alignment=TA_CENTER
)))
story.append(Spacer(1, 1*cm))
# Overall score gauge
overall = data["summary"]["score"] * 100
gauge = create_gauge_chart(overall, "Övergripande betyg")
story.append(Image(gauge, width=10*cm, height=8*cm))
story.append(Spacer(1, 0.5*cm))
# Status assessment
if overall < 50:
story.append(Paragraph(
"Webbplatsen har betydande prestandaproblem som kräver omedelbar åtgärd.",
s["alert"]))
elif overall < 75:
story.append(Paragraph(
"Webbplatsen har flera förbättringsområden som påverkar användarupplevelse och SEO.",
s["warning"]))
story.append(Spacer(1, 0.5*cm))
# Key stats table
stats_data = [
["Analyserade sidor", str(page_count)],
["Identifierade problemtyper", str(len(all_audits))],
["Resursproblem", str(len(resource_issues))],
["Elementproblem", str(len(element_issues))],
["Rapport genererad", datetime.now().strftime("%Y-%m-%d")],
]
stats_table = Table(stats_data, colWidths=[8*cm, 5*cm])
stats_table.setStyle(TableStyle([
("FONTSIZE", (0, 0), (-1, -1), 10),
("ALIGN", (1, 0), (1, -1), "RIGHT"),
("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor(UI["muted"])),
("TEXTCOLOR", (1, 0), (1, -1), brand_dark),
("FONTNAME", (1, 0), (1, -1), "Helvetica-Bold"),
("LINEBELOW", (0, -1), (-1, -1), 1, brand_primary),
]))
story.append(stats_table)
if consultant_name:
story.append(Spacer(1, 0.5*cm))
story.append(Paragraph(f"Utförd av: {consultant_name}", s["footer"]))
story.append(PageBreak())
# ===== EXECUTIVE SUMMARY =====
story.append(Paragraph("Sammanfattning", s["h1"]))
summary_text = f"""
Denna rapport presenterar resultatet av en teknisk analys av webbplatsen {site_display}.
Totalt har {page_count} sidor granskats med avseende på prestanda, tillgänglighet,
bästa praxis och sökmotoroptimering.
"""
story.append(Paragraph(summary_text, s["body"]))
# Performance summary
perf_score = data["summary"]["categories"]["performance"]["averageScore"] * 100
a11y_score = data["summary"]["categories"]["accessibility"]["averageScore"] * 100
bp_score = data["summary"]["categories"]["best-practices"]["averageScore"] * 100
seo_score = data["summary"]["categories"]["seo"]["averageScore"] * 100
findings = []
if perf_score < 70:
findings.append(f"Prestanda ({perf_score:.0f}%): Problem med laddningstider och rendering.")
if a11y_score < 90:
findings.append(f"Tillgänglighet ({a11y_score:.0f}%): Brister som påverkar användare.")
if bp_score < 80:
findings.append(f"Bästa praxis ({bp_score:.0f}%): Tekniska brister.")
if seo_score < 90:
findings.append(f"SEO ({seo_score:.0f}%): Optimeringsmöjligheter.")
if findings:
story.append(Paragraph("Huvudsakliga fynd:", s["body"]))
for f in findings:
story.append(Paragraph(f"• {f}", s["body"]))
story.append(Spacer(1, 0.3*cm))
# Category chart
cat_chart = create_overview_chart(data["summary"]["categories"], category_names)
story.append(Image(cat_chart, width=16*cm, height=6.5*cm))
story.append(Spacer(1, 0.3*cm))
# Category table
cat_table_data = [["Kategori", "Medel", "Lägsta", "Högsta", "Bedömning"]]
for cat_id, cat_info in data["summary"]["categories"].items():
avg = cat_info["averageScore"] * 100
scores = cat_info["scores"]
min_s, max_s = min(scores) * 100, max(scores) * 100
if avg >= 90:
status = "✓ Godkänt"
elif avg >= 50:
status = "⚠ Kräver åtgärd"
else:
status = "✗ Kritiskt"
cat_table_data.append([
category_names.get(cat_id, cat_id),
f"{avg:.0f}%", f"{min_s:.0f}%", f"{max_s:.0f}%", status
])
t = Table(cat_table_data, colWidths=[5*cm, 2*cm, 2*cm, 2*cm, 3*cm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), brand_dark),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, border_color),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg])
]))
story.append(t)
story.append(PageBreak())
# ===== CORE WEB VITALS =====
story.append(Paragraph("Core Web Vitals", s["h1"]))
story.append(Paragraph(
"Core Web Vitals är Googles officiella mätvärden för användarupplevelse. "
"Dessa påverkar direkt webbplatsens ranking i sökresultat.", s["body"]))
cwv_chart = create_core_web_vitals_chart(data["summary"]["metrics"])
story.append(Image(cwv_chart, width=16*cm, height=5*cm))
# Metrics table
metrics_data = [["Mätvärde", "Värde", "Bra", "Dåligt", "Status"]]
metric_info = [
("LCP (Largest Contentful Paint)", "largest-contentful-paint", 1000, "s", 2.5, 4.0),
("FCP (First Contentful Paint)", "first-contentful-paint", 1000, "s", 1.8, 3.0),
("CLS (Cumulative Layout Shift)", "cumulative-layout-shift", 1, "", 0.1, 0.25),
("TBT (Total Blocking Time)", "total-blocking-time", 1, "ms", 200, 600),
("TTI (Time to Interactive)", "interactive", 1000, "s", 3.8, 7.3),
]
for name, key, divisor, unit, good, bad in metric_info:
val = data["summary"]["metrics"].get(key, {}).get("averageNumericValue", 0) / divisor
status = "✓" if val <= good else "⚠" if val <= bad else "✗"
metrics_data.append([name, f"{val:.2f}{unit}", f"≤{good}{unit}", f">{bad}{unit}", status])
mt = Table(metrics_data, colWidths=[6.5*cm, 2.5*cm, 2*cm, 2*cm, 1.5*cm])
mt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), brand_dark),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.5, border_color),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg])
]))
story.append(mt)
story.append(PageBreak())
# ===== SCORE DISTRIBUTION =====
story.append(Paragraph("Poängfördelning per kategori", s["h1"]))
story.append(Paragraph(
f"Följande diagram visar hur de {page_count} sidorna presterar. "
"Majoriteten bör ligga i det gröna området (≥90).", s["body"]))
hist_chart = create_score_histogram(data.get("routes", []), category_names)
story.append(Image(hist_chart, width=16*cm, height=11*cm))
story.append(PageBreak())
# ===== MOST COMMON ISSUES =====
story.append(Paragraph("Vanligaste problemen", s["h1"]))
story.append(Paragraph(
"Problem som påverkar >80% av sidorna indikerar systemiska brister.", s["body"]))
issues_chart = create_issues_chart(all_audits, page_count, 12)
story.append(Image(issues_chart, width=16*cm, height=10*cm))
story.append(PageBreak())
# ===== POTENTIAL SAVINGS =====
story.append(Paragraph("Potentiella besparingar", s["h1"]))
total_savings = sum(a["total_wasted_bytes"] for a in all_audits.values())
story.append(Paragraph(
f"Total potentiell besparing: {format_bytes(total_savings)}", s["body_bold"]))
savings_chart = create_savings_chart(all_audits)
if savings_chart:
story.append(Image(savings_chart, width=15*cm, height=6*cm))
story.append(PageBreak())
# ===== DETAILED ISSUES =====
story.append(Paragraph("Detaljerade problem", s["h1"]))
for group_id, group_info in AUDIT_GROUPS.items():
group_audits = []
for audit_id, audit_data in all_audits.items():
for pattern in group_info["audits"]:
if pattern.endswith("-"):
if audit_id.startswith(pattern):
group_audits.append((audit_id, audit_data))
break
elif audit_id == pattern:
group_audits.append((audit_id, audit_data))
break
if not group_audits:
continue
group_audits.sort(key=lambda x: x[1]["count"], reverse=True)
total_in_group = sum(a[1]["count"] for a in group_audits)
if total_in_group == 0:
continue
label = "ENGÅNGSÅTGÄRD" if group_info["onetime"] else "SIDSPECIFIK"
story.append(Paragraph(
f"{group_info['title']} [{label}]", s["h2"]))
story.append(Paragraph(group_info["description"], s["small"]))
for audit_id, audit_data in group_audits[:8]:
if audit_data["count"] == 0:
continue
title = xml_escape(audit_data["title"][:65])
pct = (audit_data["count"] / page_count) * 100
story.append(Paragraph(f"• {title}", s["body"]))
details = [f"{audit_data['count']}/{page_count} sidor ({pct:.0f}%)"]
if audit_data["total_wasted_bytes"] > 1024:
details.append(f"Besparing: {format_bytes(audit_data['total_wasted_bytes'])}")
story.append(Paragraph(" " + " | ".join(details), s["small"]))
story.append(Spacer(1, 0.2*cm))
story.append(PageBreak())
# ===== RESOURCE ISSUES =====
story.append(Paragraph("Resurser som kräver åtgärd", s["h1"]))
resource_by_audit = defaultdict(list)
for (audit_id, url), rdata in resource_issues.items():
if rdata["count"] >= 10 and rdata["wasted_bytes"] > 5000:
resource_by_audit[audit_id].append((url, rdata))
for audit_id in ["unused-css-rules", "unused-javascript", "render-blocking-resources",
"modern-image-formats", "uses-long-cache-ttl"]:
resources = resource_by_audit.get(audit_id, [])
if not resources:
continue
audit_title = all_audits.get(audit_id, {}).get("title", audit_id)
story.append(Paragraph(f"{xml_escape(audit_title[:50])}", s["h3"]))
resources.sort(key=lambda x: x[1]["wasted_bytes"], reverse=True)
resource_table = [["Resurs", "Sidor", "Bortkastade data"]]
for url, rdata in resources[:8]:
url_display = "..." + url[-42:] if len(url) > 45 else url
resource_table.append([
url_display, str(rdata["count"]), format_bytes(rdata["wasted_bytes"])
])
if len(resource_table) > 1:
rt = Table(resource_table, colWidths=[9*cm, 2*cm, 3*cm])
rt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), brand_dark),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTSIZE", (0, 0), (-1, -1), 7),
("FONTNAME", (0, 1), (0, -1), "Courier"),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, border_color),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg])
]))
story.append(rt)
story.append(Spacer(1, 0.2*cm))
story.append(PageBreak())
# ===== WORST PAGES =====
story.append(Paragraph("Sidor som kräver mest arbete", s["h1"]))
routes = data.get("routes", [])
for cat_id, cat_name in [("performance", "Prestanda"), ("accessibility", "Tillgänglighet")]:
story.append(Paragraph(f"{cat_name} – sämsta 10", s["h2"]))
sorted_routes = sorted(
routes, key=lambda r: r.get("categories", {}).get(cat_id, {}).get("score", 1)
)[:10]
worst_table = [["Sida", "Poäng"]]
for route in sorted_routes:
score = route.get("categories", {}).get(cat_id, {}).get("score", 1) * 100
path = route.get("path", "/")
if len(path) > 50:
path = path[:47] + "..."
worst_table.append([path, f"{score:.0f}%"])
wt = Table(worst_table, colWidths=[12*cm, 2.5*cm])
wt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), brand_dark),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTSIZE", (0, 0), (-1, -1), 8),
("ALIGN", (1, 0), (1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, border_color),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg])
]))
story.append(wt)
story.append(Spacer(1, 0.3*cm))
story.append(PageBreak())
# ===== RECOMMENDATIONS =====
story.append(Paragraph("Rekommenderade åtgärder", s["h1"]))
story.append(Paragraph("Fas 1: Engångsåtgärder", s["h2"]))
story.append(Paragraph("Åtgärdas en gång, förbättrar alla sidor automatiskt.", s["small"]))
phase1 = [
("Bildoptimering",
f"WebP-konvertering och komprimering. Besparing: {format_bytes(all_audits.get('modern-image-formats', {}).get('total_wasted_bytes', 0))}.",
"2-4 tim"),
("Caching och minifiering",
"Aktivera minifiering av CSS/JS, lazy loading och browser caching.",
"4-6 tim"),
("Render-blocking resurser",
"Kritisk CSS-inlining och uppskjuten JavaScript-laddning.",
"2-4 tim"),
("Console-fel",
f"Åtgärda JavaScript-fel på {all_audits.get('errors-in-console', {}).get('count', 0)} sidor.",
"2-4 tim"),
]
for title, desc, time in phase1:
story.append(Paragraph(f"{title} [{time}]", s["body"]))
story.append(Paragraph(desc, s["small"]))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Fas 2: Sidspecifika förbättringar", s["h2"]))
phase2 = [
("Färgkontrast",
f"{all_audits.get('color-contrast', {}).get('count', 0)} sidor har otillräcklig kontrast.",
f"{all_audits.get('color-contrast', {}).get('count', 0) * 15} min"),
("Länktexter",
f"{all_audits.get('link-name', {}).get('count', 0)} sidor har länkar utan beskrivande text.",
f"{all_audits.get('link-name', {}).get('count', 0) * 10} min"),
("Layout Shift (CLS)",
"Definiera dimensioner på bilder, undvik dynamiskt innehåll.",
"4-8 tim"),
]
for title, desc, time in phase2:
story.append(Paragraph(f"{title} [{time}]", s["body"]))
story.append(Paragraph(desc, s["small"]))
story.append(Spacer(1, 0.5*cm))
# Time estimate
story.append(Paragraph("Sammanfattning arbetsinsats", s["h2"]))
total_time_low = int(15 + page_count * 0.25)
total_time_high = int(28 + page_count * 0.3)
time_data = [
["Fas", "Åtgärd", "Tid", "Påverkan"],
["1", "Bildoptimering + CDN", "4-8 tim", f"{page_count} sidor"],
["1", "Caching & minifiering", "4-6 tim", f"{page_count} sidor"],
["1", "Grundläggande a11y", "1-2 tim", f"{page_count} sidor"],
["1", "Felsökning", "2-4 tim", f"{page_count} sidor"],
["2", "Färgkontrast & länkar", f"{int(page_count * 0.2)}-{int(page_count * 0.3)} tim", "Specifika sidor"],
["2", "CLS-optimering", "4-8 tim", "Berörda mallar"],
["", "TOTALT ESTIMAT", f"{total_time_low}-{total_time_high} tim", ""],
]
tt = Table(time_data, colWidths=[1.2*cm, 7*cm, 3*cm, 3*cm])
tt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), brand_dark),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (2, 0), (3, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, border_color),
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, light_bg]),
("BACKGROUND", (0, -1), (-1, -1), brand_primary),
("TEXTCOLOR", (0, -1), (-1, -1), colors.white),
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
("FONTSIZE", (0, -1), (-1, -1), 10),
]))
story.append(tt)
# Build PDF
doc.build(story)