""" 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)