583 lines
23 KiB
Python
583 lines
23 KiB
Python
"""
|
||
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)
|
||
|