Files
webbanalys/lib/pdf_report.py
2025-12-13 17:33:36 +01:00

583 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 <b>{page_count} sidor</b> 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"<b>Prestanda ({perf_score:.0f}%)</b>: Problem med laddningstider och rendering.")
if a11y_score < 90:
findings.append(f"<b>Tillgänglighet ({a11y_score:.0f}%)</b>: Brister som påverkar användare.")
if bp_score < 80:
findings.append(f"<b>Bästa praxis ({bp_score:.0f}%)</b>: Tekniska brister.")
if seo_score < 90:
findings.append(f"<b>SEO ({seo_score:.0f}%)</b>: Optimeringsmöjligheter.")
if findings:
story.append(Paragraph("<b>Huvudsakliga fynd:</b>", 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"<b>Total potentiell besparing: {format_bytes(total_savings)}</b>", 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']} <font color='#{UI['muted'][1:]}'>[{label}]</font>", 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"<b>• {title}</b>", 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"<b>{xml_escape(audit_title[:50])}</b>", 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"<b>{title}</b> <font color='#{UI['muted'][1:]}'>[{time}]</font>", 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"<b>{title}</b> <font color='#{UI['muted'][1:]}'>[{time}]</font>", 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)