Modules/Outputs/Reports/40-PowerPointOutput.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    v2.5.0 (#80): PowerPoint .pptx deck generator — executive environment overview.
.DESCRIPTION
    Builds a minimal, standards-compliant OOXML .pptx directly via System.IO.Packaging
    so the module has zero extra dependencies (no Office, no PowerShell PowerPoint
    modules). Produces a 7-slide deck suitable for handoff, design review, or
    customer presentation.
#>


function New-RangerPptxDeck {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest,
        [Parameter(Mandatory = $true)] [string]$OutputPath
    )

    Add-Type -AssemblyName System.IO.Packaging -ErrorAction SilentlyContinue
    Add-Type -AssemblyName WindowsBase -ErrorAction SilentlyContinue

    $cluster = [string]($Manifest.topology.clusterName ?? $Manifest.domains.clusterNode.cluster.name ?? 'Unknown')
    $runDate = [string]($Manifest.run.endTimeUtc ?? (Get-Date -Format 'yyyy-MM-dd'))
    $nodes = @($Manifest.domains.clusterNode.nodes)
    $vms = @($Manifest.domains.virtualMachines.inventory)
    $wafSummary = $Manifest.domains.wafAssessment.summary
    $capSummary = $Manifest.domains.capacityAnalysis.summary
    $vmUtilSummary = $Manifest.domains.vmUtilization.summary
    $storageSummary = $Manifest.domains.storage.summary

    $slides = @(
        @{
            title = "Azure Local — $cluster"
            lines = @(
                "Environment overview",
                "Run date: $runDate",
                "Generated by AzureLocalRanger"
            )
        },
        @{
            title = 'Executive Summary'
            lines = @(
                "Nodes: $($nodes.Count)"
                "VMs: $($vms.Count)"
                ("WAF Score: {0}% ({1})" -f ([int]($wafSummary.overallScore ?? 0)), ([string]($wafSummary.status ?? 'Unknown')))
                ("Failing rules: {0}" -f ([int]($wafSummary.failingRules ?? 0)))
            )
        },
        @{
            title = 'Cluster Overview'
            lines = @(
                "Cluster name: $cluster"
                ("Deployment type: {0}" -f ([string]($Manifest.topology.deploymentType ?? 'unknown')))
                ("Identity mode: {0}" -f ([string]($Manifest.topology.identityMode ?? 'unknown')))
                ("Storage architecture: {0}" -f ([string]($Manifest.topology.storageArchitecture ?? 'unknown')))
                ("Control plane: {0}" -f ([string]($Manifest.topology.controlPlaneMode ?? 'unknown')))
            )
        },
        @{
            title = 'Capacity Headroom'
            lines = if ($capSummary) {
                @(
                    ("vCPU allocation: {0}% ({1})" -f ([double]$capSummary.vcpuUtilizationPct), ([string]$capSummary.vcpuStatus))
                    ("Memory allocation: {0}% ({1})" -f ([double]$capSummary.memoryUtilizationPct), ([string]$capSummary.memoryStatus))
                    ("Storage used: {0}% ({1})" -f ([double]$capSummary.storageUtilizationPct), ([string]$capSummary.storageStatus))
                    ("Pool allocated: {0}% ({1})" -f ([double]$capSummary.poolUtilizationPct), ([string]$capSummary.poolStatus))
                )
            } else { @('No capacity analysis data available.') }
        },
        @{
            title = 'Workload Efficiency'
            lines = if ($vmUtilSummary) {
                @(
                    ("Total VMs: {0}" -f ([int]$vmUtilSummary.vmCount))
                    ("Idle VMs: {0}" -f ([int]$vmUtilSummary.idleCount))
                    ("Underutilized VMs: {0}" -f ([int]$vmUtilSummary.underutilizedCount))
                    ("Potential vCPU freed: {0}" -f ([int]$vmUtilSummary.potentialVcpuFreed))
                    ("Potential memory freed: {0} GiB" -f ([double]$vmUtilSummary.potentialMemoryFreedGiB))
                )
            } else { @('No VM utilization data collected.') }
        },
        @{
            title = 'Storage Summary'
            lines = if ($storageSummary) {
                @(
                    ("Storage pools: {0}" -f ([int]$storageSummary.poolCount))
                    ("Physical disks: {0}" -f ([int]$storageSummary.physicalDiskCount))
                    ("Virtual disks: {0}" -f ([int]$storageSummary.virtualDiskCount))
                    ("Volumes: {0}" -f ([int]$storageSummary.volumeCount))
                    ("Unhealthy disks: {0}" -f ([int]$storageSummary.unhealthyDisks))
                )
            } else { @('No storage summary data available.') }
        },
        @{
            title = 'Next Steps'
            lines = @(
                'Review WAF Compliance Roadmap (Now / Next / Later)'
                'Schedule capacity expansion if any status is Warning or Critical'
                'Validate idle VMs for decommission or rightsize'
                'Re-run Ranger quarterly for drift detection'
            )
        }
    )

    # Use OPC packaging to build the .pptx
    if (Test-Path $OutputPath) { Remove-Item $OutputPath -Force }
    $pkg = [System.IO.Packaging.Package]::Open($OutputPath, [System.IO.FileMode]::Create)
    try {
        Add-PptxCorePart -Package $pkg
        Add-PptxPresentationPart -Package $pkg -SlideCount $slides.Count
        for ($i = 0; $i -lt $slides.Count; $i++) {
            Add-PptxSlidePart -Package $pkg -Index ($i + 1) -Slide $slides[$i]
        }
    } finally {
        $pkg.Close()
    }

    return [ordered]@{
        status       = 'ok'
        path         = $OutputPath
        slideCount   = $slides.Count
        sizeBytes    = (Get-Item $OutputPath).Length
    }
}

function Add-PptxCorePart {
    param([System.IO.Packaging.Package]$Package)

    $relsUri = [System.IO.Packaging.PackUriHelper]::CreatePartUri('/_rels/.rels')
    # Implicitly created by AddRelationship below.

    $null = $Package.CreateRelationship(
        [uri]'/ppt/presentation.xml',
        [System.IO.Packaging.TargetMode]::Internal,
        'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
        'rId1')
}

function Add-PptxPresentationPart {
    param(
        [System.IO.Packaging.Package]$Package,
        [int]$SlideCount
    )

    $presUri = [System.IO.Packaging.PackUriHelper]::CreatePartUri('/ppt/presentation.xml')
    $pres = $Package.CreatePart($presUri, 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml')

    $slideIdEntries = for ($i = 1; $i -le $SlideCount; $i++) {
        " <p:sldId id='$(255 + $i)' r:id='rId$i'/>"
    }
    $slideIdsXml = ($slideIdEntries -join [Environment]::NewLine)

    $presXml = @"
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<p:presentation xmlns:a='http://schemas.openxmlformats.org/drawingml/2006/main'
                xmlns:r='http://schemas.openxmlformats.org/officeDocument/2006/relationships'
                xmlns:p='http://schemas.openxmlformats.org/presentationml/2006/main'>
  <p:sldIdLst>
$slideIdsXml
  </p:sldIdLst>
  <p:sldSz cx='9144000' cy='6858000' type='screen4x3'/>
  <p:notesSz cx='6858000' cy='9144000'/>
</p:presentation>
"@


    $sw = New-Object System.IO.StreamWriter($pres.GetStream(), [System.Text.Encoding]::UTF8)
    try { $sw.Write($presXml) } finally { $sw.Close() }

    for ($i = 1; $i -le $SlideCount; $i++) {
        $null = $pres.CreateRelationship(
            [uri]"/ppt/slides/slide$i.xml",
            [System.IO.Packaging.TargetMode]::Internal,
            'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide',
            "rId$i")
    }
}

function Add-PptxSlidePart {
    param(
        [System.IO.Packaging.Package]$Package,
        [int]$Index,
        [hashtable]$Slide
    )

    $uri = [System.IO.Packaging.PackUriHelper]::CreatePartUri("/ppt/slides/slide$Index.xml")
    $part = $Package.CreatePart($uri, 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml')

    $titleEsc = [System.Security.SecurityElement]::Escape([string]$Slide.title)
    $bodyParagraphs = foreach ($line in @($Slide.lines)) {
        $e = [System.Security.SecurityElement]::Escape([string]$line)
        "<a:p><a:r><a:rPr lang='en-US' sz='1800'/><a:t>$e</a:t></a:r></a:p>"
    }
    $bodyXml = ($bodyParagraphs -join '')

    $xml = @"
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<p:sld xmlns:a='http://schemas.openxmlformats.org/drawingml/2006/main'
       xmlns:r='http://schemas.openxmlformats.org/officeDocument/2006/relationships'
       xmlns:p='http://schemas.openxmlformats.org/presentationml/2006/main'>
  <p:cSld>
    <p:spTree>
      <p:nvGrpSpPr><p:cNvPr id='1' name=''/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
      <p:grpSpPr><a:xfrm><a:off x='0' y='0'/><a:ext cx='9144000' cy='6858000'/><a:chOff x='0' y='0'/><a:chExt cx='9144000' cy='6858000'/></a:xfrm></p:grpSpPr>
      <p:sp>
        <p:nvSpPr><p:cNvPr id='2' name='Title'/><p:cNvSpPr><a:spLocks noGrp='1'/></p:cNvSpPr><p:nvPr><p:ph type='title'/></p:nvPr></p:nvSpPr>
        <p:spPr><a:xfrm><a:off x='457200' y='274638'/><a:ext cx='8229600' cy='800000'/></a:xfrm></p:spPr>
        <p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang='en-US' sz='3200' b='1'/><a:t>$titleEsc</a:t></a:r></a:p></p:txBody>
      </p:sp>
      <p:sp>
        <p:nvSpPr><p:cNvPr id='3' name='Content'/><p:cNvSpPr><a:spLocks noGrp='1'/></p:cNvSpPr><p:nvPr><p:ph idx='1'/></p:nvPr></p:nvSpPr>
        <p:spPr><a:xfrm><a:off x='457200' y='1200000'/><a:ext cx='8229600' cy='5200000'/></a:xfrm></p:spPr>
        <p:txBody><a:bodyPr wrap='square'/><a:lstStyle/>$bodyXml</p:txBody>
      </p:sp>
    </p:spTree>
  </p:cSld>
</p:sld>
"@


    $sw = New-Object System.IO.StreamWriter($part.GetStream(), [System.Text.Encoding]::UTF8)
    try { $sw.Write($xml) } finally { $sw.Close() }
}