.claude/worktrees/agent-a920ebd2f97275b33/src/report/renderers/Export-Pptx.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Auto-assemble an executive PowerPoint deck from scored findings via the
    OpenXML SDK (DocumentFormat.OpenXml) — no Python, no PowerPoint install.
 
.DESCRIPTION
    Renders the Tier-3 executive deck directly from the scored Findings object
    (the output of Get-Score: GeneratedOn/Frameworks/Areas/Gaps/Manual/Errors/
    Findings). Every element of the .pptx OPC package (theme, slide master,
    slide layouts, slides, tables) is constructed programmatically with the
    OpenXML SDK — this is the accepted design in
    docs/design/decisions/pptx-renderer.md (AB#5044).
 
    Slide inventory:
      1. Title — deck branding, generated date, scope/mgmt-group if known
      2. Executive Summary — CAF/WAF score cards + rollup counts
      3..N Area Breakdown — Framework/Area/Score/Pass/Partial/Fail/Manual table,
                             paginated at 10 rows/slide
      N+1.. Prioritized Gaps — Severity/Area/Gap table, sorted worst-first,
                             paginated at 10 rows/slide, capped at top 15
      .. Manual Review — outstanding manual-review worklist (capped at 12 rows)
      .. Next Steps — closing recommendations
 
    TEMPLATE-AS-CODE EXTENSION POINT: the theme/master/layout/chrome built by
    New-ScoutDeckShell and Add-ScoutSlideChrome below is the programmatic
    stand-in for a designer-authored deck.pptx.template (see the decision
    record's amendment). A future iteration can replace this function pair
    with code that opens a designer-authored .pptx template and clones its
    slide master/layout parts instead of building them from scratch here —
    every other function in this file (the slide-content builders) is
    unaffected by that swap, since they only depend on the shell exposing a
    content SlideLayoutPart + title SlideLayoutPart relationship ids.
 
.NOTES
    Tracks ADO Story AB#5048, Epic AB#5044. Carries forward the AB#5089
    defensive severity guard from the retired build_deck.py (null/unknown
    severity sorts LAST and never throws — see Get-ScoutSeverityRank/-Label).
#>


#region Assembly acquisition (first-use NuGet acquire + cache, no committed binaries)

# Pinned so every run resolves the exact same OpenXML SDK build; bump deliberately.
$Script:ScoutOpenXmlVersion = '3.0.2'

function Import-ScoutOpenXmlAssembly {
    [CmdletBinding()]
    param()

    # Idempotent within a process — a prior Export-Pptx call (or Pester run)
    # in the same pwsh session already loaded these.
    $loaded = [System.AppDomain]::CurrentDomain.GetAssemblies() |
        Where-Object { $_.GetName().Name -eq 'DocumentFormat.OpenXml' }
    if ($loaded) { return }

    $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent
    $cacheDir = Join-Path $repoRoot 'output' '.tools' 'openxml' $Script:ScoutOpenXmlVersion
    $requiredDlls = @('DocumentFormat.OpenXml.Framework.dll', 'System.IO.Packaging.dll', 'DocumentFormat.OpenXml.dll')

    $haveAll = -not ($requiredDlls | Where-Object { -not (Test-Path (Join-Path $cacheDir $_)) })
    if (-not $haveAll) {
        if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
            throw "Export-Pptx: DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) is not cached at '$cacheDir' and the 'dotnet' SDK is not on PATH to acquire it. Install the .NET SDK (or pre-seed the cache folder with the three DLLs above) and retry."
        }

        New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
        $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzScoutOpenXmlAcquire_$([guid]::NewGuid().ToString('N'))"
        New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
        $tempProj = Join-Path $tempDir 'acquire.csproj'
        @"
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="DocumentFormat.OpenXml" Version="$($Script:ScoutOpenXmlVersion)" />
  </ItemGroup>
</Project>
"@
 | Out-File -FilePath $tempProj -Encoding utf8

        Write-Host "[Export-Pptx] Acquiring DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) via dotnet/NuGet (first use — cached under $cacheDir for subsequent runs)..." -ForegroundColor Cyan $buildOutput = & dotnet build $tempProj -c Release -o $cacheDir --nologo 2>&1 $exitCode = $LASTEXITCODE Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem $cacheDir -Filter 'acquire.*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue if ($exitCode -ne 0 -or -not (Test-Path (Join-Path $cacheDir 'DocumentFormat.OpenXml.dll'))) { throw "Export-Pptx: could not acquire DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) (offline, and nothing cached at '$cacheDir'?). dotnet build exit code $exitCode.`n$($buildOutput -join "`n")" } Write-Host "[Export-Pptx] DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) cached at $cacheDir" -ForegroundColor Green } foreach ($dll in $requiredDlls) { Add-Type -Path (Join-Path $cacheDir $dll) -ErrorAction Stop } } #endregion #region Low-level OpenXML element/part helpers # # IMPORTANT: every OpenXmlElement in this SDK implements IEnumerable<OpenXmlElement> # over its own children. PowerShell's function-output pipeline auto-enumerates any # IEnumerable it sees, so a bare "return $element" silently flattens the element # into its (often zero) children instead of returning the element itself. Every # helper below returns via the unary comma operator (`return ,$x`) to suppress # that unrolling — do not remove the commas.
#
# For the same reason, never build an array of OpenXmlElement objects with `@(...)`
# or a bare comma list — use New-ScoutList + .Add(), which is a plain .NET method
# call and is not subject to pipeline enumeration.

$Script:PresNs = 'DocumentFormat.OpenXml.Presentation'
$Script:DrawNs = 'DocumentFormat.OpenXml.Drawing'

function New-ScoutEl {
    param([Parameter(Mandatory)][string]$TypeName)
    $o = New-Object -TypeName $TypeName
    return , $o
}

function New-ScoutList {
    return , ([System.Collections.Generic.List[object]]::new())
}

function New-ScoutPart {
    # Wraps the generic OpenXmlPartContainer.AddNewPart<T>(string id) method,
    # which PowerShell cannot call with normal method-call syntax.
    param($Parent, [Parameter(Mandatory)][type]$PartType, [Parameter(Mandatory)][string]$Id)
    $mi = $Parent.GetType().GetMethods() | Where-Object {
        $_.Name -eq 'AddNewPart' -and $_.IsGenericMethodDefinition -and
        $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType -eq [string]
    } | Select-Object -First 1
    if (-not $mi) { throw "Export-Pptx: could not locate generic AddNewPart<T> on $($Parent.GetType().FullName)." }
    $gmi = $mi.MakeGenericMethod($PartType)
    $part = $gmi.Invoke($Parent, @($Id))
    return , $part
}

function ScoutEmu {
    # EMU (English Metric Units), 914400 per inch. Int64 — used for a:off/a:ext,
    # table row heights and grid column widths (ST_PositiveCoordinate).
    param([double]$Inches)
    return [int64][math]::Round($Inches * 914400)
}

function ScoutEmu32 {
    # Int32 EMU — required specifically by p:sldSz / p:notesSz (ST_PositiveCoordinate32).
    param([double]$Inches)
    return [int32][math]::Round($Inches * 914400)
}

#endregion

#region Fill / text / table-cell helpers

function New-ScoutFill {
    param([Parameter(Mandatory)][string]$Hex)
    $fill = New-ScoutEl "$Script:DrawNs.SolidFill"
    $rgb = New-ScoutEl "$Script:DrawNs.RgbColorModelHex"
    $rgb.Val = $Hex
    $fill.Append($rgb)
    return , $fill
}

function New-ScoutPhColor {
    # Placeholder scheme-color reference used inside a theme's FormatScheme
    # boilerplate (fill/line/effect/background style lists).
    $sc = New-ScoutEl "$Script:DrawNs.SchemeColor"
    $sc.Val = [DocumentFormat.OpenXml.Drawing.SchemeColorValues]::PhColor
    return , $sc
}

function New-ScoutRun {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [double]$SizePt = 12,
        [string]$Hex = '1A1A1A',
        [bool]$Bold = $false,
        [string]$Font = 'Segoe UI'
    )
    $run = New-ScoutEl "$Script:DrawNs.Run"
    $rPr = New-ScoutEl "$Script:DrawNs.RunProperties"
    $rPr.Language = 'en-US'
    $rPr.FontSize = [int]([math]::Round($SizePt * 100))
    $rPr.Bold = $Bold
    $rPr.Append((New-ScoutFill $Hex))
    $latin = New-ScoutEl "$Script:DrawNs.LatinFont"
    $latin.Typeface = $Font
    $rPr.Append($latin)
    $run.Append($rPr)
    $t = New-ScoutEl "$Script:DrawNs.Text"
    $t.Text = $Text
    $run.Append($t)
    return , $run
}

function New-ScoutPara {
    # $Runs: a List[object] of Drawing.Run elements (built with New-ScoutList/.Add()).
    param($Runs, [string]$Align = $null, [double]$SpaceBeforePt = 0)
    $p = New-ScoutEl "$Script:DrawNs.Paragraph"
    if ($Align -or $SpaceBeforePt -gt 0) {
        $pPr = New-ScoutEl "$Script:DrawNs.ParagraphProperties"
        if ($Align) {
            $pPr.Alignment = switch ($Align) {
                'ctr' { [DocumentFormat.OpenXml.Drawing.TextAlignmentTypeValues]::Center }
                'r' { [DocumentFormat.OpenXml.Drawing.TextAlignmentTypeValues]::Right }
                default { [DocumentFormat.OpenXml.Drawing.TextAlignmentTypeValues]::Left }
            }
        }
        if ($SpaceBeforePt -gt 0) {
            $spcBef = New-ScoutEl "$Script:DrawNs.SpaceBefore"
            $spcPts = New-ScoutEl "$Script:DrawNs.SpacingPoints"
            $spcPts.Val = [int]([math]::Round($SpaceBeforePt * 100))
            $spcBef.Append($spcPts)
            $pPr.Append($spcBef)
        }
        $p.Append($pPr)
    }
    foreach ($r in $Runs) { $p.Append($r) }
    return , $p
}

function New-ScoutShapeTextBody {
    # $Paragraphs: a List[object] of Drawing.Paragraph — wrapped for a p:sp (shape).
    param($Paragraphs, [string]$Anchor = 't', [bool]$Wrap = $true)
    $tb = New-ScoutEl "$Script:PresNs.TextBody"
    $bp = New-ScoutEl "$Script:DrawNs.BodyProperties"
    $bp.Wrap = if ($Wrap) { [DocumentFormat.OpenXml.Drawing.TextWrappingValues]::Square } else { [DocumentFormat.OpenXml.Drawing.TextWrappingValues]::None }
    $bp.Anchor = switch ($Anchor) {
        'ctr' { [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Center }
        'b' { [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Bottom }
        default { [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Top }
    }
    $tb.Append($bp)
    $tb.Append((New-ScoutEl "$Script:DrawNs.ListStyle"))
    foreach ($p in $Paragraphs) { $tb.Append($p) }
    return , $tb
}

function New-ScoutCellTextBody {
    # $Paragraphs: a List[object] of Drawing.Paragraph — wrapped for an a:tc (table cell).
    param($Paragraphs)
    $tb = New-ScoutEl "$Script:DrawNs.TextBody"
    $tb.Append((New-ScoutEl "$Script:DrawNs.BodyProperties"))
    $tb.Append((New-ScoutEl "$Script:DrawNs.ListStyle"))
    foreach ($p in $Paragraphs) { $tb.Append($p) }
    return , $tb
}

#endregion

#region Shape / table builders

$Script:ScoutShapeId = 10

function New-ScoutShape {
    # A rectangle or rounded rectangle with optional fill and text content —
    # the one shape primitive every slide/card/label in this deck is built from.
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][double]$X, [Parameter(Mandatory)][double]$Y,
        [Parameter(Mandatory)][double]$Cx, [Parameter(Mandatory)][double]$Cy,
        [string]$FillHex = $null,
        $Paragraphs,
        [string]$Anchor = 't',
        [double]$RadiusPct = 0
    )
    $Script:ScoutShapeId++
    $sh = New-ScoutEl "$Script:PresNs.Shape"

    $nvSp = New-ScoutEl "$Script:PresNs.NonVisualShapeProperties"
    $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
    $cnv.Id = [uint32]$Script:ScoutShapeId
    $cnv.Name = $Name
    $nvSp.Append($cnv)
    $nvSp.Append((New-ScoutEl "$Script:PresNs.NonVisualShapeDrawingProperties"))
    $nvSp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
    $sh.Append($nvSp)

    $spPr = New-ScoutEl "$Script:PresNs.ShapeProperties"
    $xfrm = New-ScoutEl "$Script:DrawNs.Transform2D"
    $off = New-ScoutEl "$Script:DrawNs.Offset"; $off.X = (ScoutEmu $X); $off.Y = (ScoutEmu $Y)
    $ext = New-ScoutEl "$Script:DrawNs.Extents"; $ext.Cx = (ScoutEmu $Cx); $ext.Cy = (ScoutEmu $Cy)
    $xfrm.Append($off); $xfrm.Append($ext)
    $spPr.Append($xfrm)

    $geom = New-ScoutEl "$Script:DrawNs.PresetGeometry"
    $geom.Preset = if ($RadiusPct -gt 0) {
        [DocumentFormat.OpenXml.Drawing.ShapeTypeValues]::RoundRectangle
    } else {
        [DocumentFormat.OpenXml.Drawing.ShapeTypeValues]::Rectangle
    }
    $avLst = New-ScoutEl "$Script:DrawNs.AdjustValueList"
    if ($RadiusPct -gt 0) {
        $av = New-ScoutEl "$Script:DrawNs.ShapeGuide"
        $av.Name = 'adj'
        $av.Formula = "val $([int]($RadiusPct * 1000))"
        $avLst.Append($av)
    }
    $geom.Append($avLst)
    $spPr.Append($geom)

    if ($FillHex) { $spPr.Append((New-ScoutFill $FillHex)) }
    else { $spPr.Append((New-ScoutEl "$Script:DrawNs.NoFill")) }
    $noLineOutline = New-ScoutEl "$Script:DrawNs.Outline"
    $noLineOutline.Append((New-ScoutEl "$Script:DrawNs.NoFill"))
    $spPr.Append($noLineOutline)
    $sh.Append($spPr)

    if ($Paragraphs) { $sh.Append((New-ScoutShapeTextBody -Paragraphs $Paragraphs -Anchor $Anchor)) }
    return , $sh
}

function New-ScoutTableCell {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [double]$SizePt = 11,
        [string]$Hex = '1A1A1A',
        [bool]$Bold = $false,
        [string]$FillHex = 'FFFFFF',
        [string]$Align = $null
    )
    $tc = New-ScoutEl "$Script:DrawNs.TableCell"
    $runs = New-ScoutList
    $runs.Add((New-ScoutRun -Text $Text -SizePt $SizePt -Hex $Hex -Bold $Bold))
    $paras = New-ScoutList
    $paras.Add((New-ScoutPara -Runs $runs -Align $Align))
    $tc.Append((New-ScoutCellTextBody $paras))

    $tcPr = New-ScoutEl "$Script:DrawNs.TableCellProperties"
    $tcPr.Anchor = [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Center
    $tcPr.LeftMargin = [int32](ScoutEmu 0.08)
    $tcPr.RightMargin = [int32](ScoutEmu 0.08)
    $tcPr.TopMargin = [int32](ScoutEmu 0.02)
    $tcPr.BottomMargin = [int32](ScoutEmu 0.02)
    if ($FillHex) { $tcPr.Append((New-ScoutFill $FillHex)) }
    $tc.Append($tcPr)
    return , $tc
}

function New-ScoutTableRow {
    param([double]$HeightIn, $Cells)
    $row = New-ScoutEl "$Script:DrawNs.TableRow"
    $row.Height = (ScoutEmu $HeightIn)
    foreach ($c in $Cells) { $row.Append($c) }
    return , $row
}

function New-ScoutTable {
    # $ColWidthsIn: double[] of column widths in inches.
    # $Rows: List[object] of Drawing.TableRow (header row typically at index 0).
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][double]$X, [Parameter(Mandatory)][double]$Y,
        [Parameter(Mandatory)][double[]]$ColWidthsIn,
        [Parameter(Mandatory)]$Rows
    )
    $Script:ScoutShapeId++
    $gf = New-ScoutEl "$Script:PresNs.GraphicFrame"

    $nvGfp = New-ScoutEl "$Script:PresNs.NonVisualGraphicFrameProperties"
    $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
    $cnv.Id = [uint32]$Script:ScoutShapeId
    $cnv.Name = $Name
    $nvGfp.Append($cnv)
    $locks = New-ScoutEl "$Script:DrawNs.GraphicFrameLocks"; $locks.NoGrouping = $true
    $nvGfdp = New-ScoutEl "$Script:PresNs.NonVisualGraphicFrameDrawingProperties"
    $nvGfdp.Append($locks)
    $nvGfp.Append($nvGfdp)
    $nvGfp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
    $gf.Append($nvGfp)

    $totalWidth = ($ColWidthsIn | Measure-Object -Sum).Sum
    $totalHeight = 0.0
    foreach ($r in $Rows) { $totalHeight += ([double]$r.Height.Value / 914400.0) }

    $xfrm = New-ScoutEl "$Script:PresNs.Transform"
    $off = New-ScoutEl "$Script:DrawNs.Offset"; $off.X = (ScoutEmu $X); $off.Y = (ScoutEmu $Y)
    $ext = New-ScoutEl "$Script:DrawNs.Extents"; $ext.Cx = (ScoutEmu $totalWidth); $ext.Cy = (ScoutEmu $totalHeight)
    $xfrm.Append($off); $xfrm.Append($ext)
    $gf.Append($xfrm)

    $graphic = New-ScoutEl "$Script:DrawNs.Graphic"
    $graphicData = New-ScoutEl "$Script:DrawNs.GraphicData"
    $graphicData.Uri = 'http://schemas.openxmlformats.org/drawingml/2006/table'

    $table = New-ScoutEl "$Script:DrawNs.Table"
    $tblPr = New-ScoutEl "$Script:DrawNs.TableProperties"
    $tblPr.FirstRow = $true
    $tblPr.BandRow = $true
    $table.Append($tblPr)

    $grid = New-ScoutEl "$Script:DrawNs.TableGrid"
    foreach ($w in $ColWidthsIn) {
        $gc = New-ScoutEl "$Script:DrawNs.GridColumn"
        $gc.Width = (ScoutEmu $w)
        $grid.Append($gc)
    }
    $table.Append($grid)
    foreach ($r in $Rows) { $table.Append($r) }

    $graphicData.Append($table)
    $graphic.Append($graphicData)
    $gf.Append($graphic)

    return , $gf
}

#endregion

#region Data helpers (safe property access, score/severity bands, pagination)

function Get-ScoutProp {
    param($Obj, [Parameter(Mandatory)][string]$Name, $Default = $null)
    if ($null -eq $Obj) { return $Default }
    $p = $Obj.PSObject.Properties[$Name]
    if ($p) { return $p.Value } else { return $Default }
}

function Get-ScoutScoreColor {
    param($Score)
    if ($null -eq $Score) { return '595959' }
    if ($Score -ge 80) { return '2E7D32' }
    if ($Score -ge 50) { return 'B8860B' }
    return 'B00020'
}

$Script:ScoutSeverityRank = @{ high = 0; medium = 1; low = 2 }

function Get-ScoutSeverityRank {
    # AB#5089: null/missing/unrecognized severity sorts LAST, never throws.
    param($Severity)
    if ($Severity) {
        $key = $Severity.ToString().Trim().ToLowerInvariant()
        if ($Script:ScoutSeverityRank.ContainsKey($key)) { return $Script:ScoutSeverityRank[$key] }
    }
    return 99
}

function Get-ScoutSeverityLabel {
    # AB#5089 companion: always renders a label, never crashes on null/blank.
    param($Severity)
    if ($Severity -and "$Severity".Trim()) { return "$Severity".ToUpperInvariant() }
    return 'UNKNOWN'
}

function Get-ScoutSeverityColor {
    param($Severity)
    if (-not $Severity) { return '595959' }
    switch ($Severity.ToString().Trim().ToLowerInvariant()) {
        'high' { return 'B00020' }
        'medium' { return 'B8860B' }
        'low' { return '2E75B6' }
        default { return '595959' }
    }
}

function Split-ScoutChunks {
    param([array]$Items, [int]$Size)
    $chunks = [System.Collections.Generic.List[object]]::new()
    if (-not $Items -or $Items.Count -eq 0) { return $chunks }
    for ($i = 0; $i -lt $Items.Count; $i += $Size) {
        $endIdx = [Math]::Min($i + $Size, $Items.Count) - 1
        $chunks.Add(@($Items[$i..$endIdx]))
    }
    return $chunks
}

#endregion

#region Palette

$Script:Navy = '1F4E78'
$Script:Steel = '2E75B6'
$Script:Green = '2E7D32'
$Script:Gold = 'B8860B'
$Script:Red = 'B00020'
$Script:Ink = '1A1A1A'
$Script:Paper = 'FFFFFF'
$Script:Mist = 'F6F9FD'
$Script:Line = 'E2E2E2'
$Script:Gray = '595959'

$Script:SlideWIn = 13.333
$Script:SlideHIn = 7.5

#endregion

#region Deck shell — theme, slide master, two slide layouts (title-as-code template)

function New-ScoutDeckShell {
    param([Parameter(Mandatory)][string]$OutFile)

    if (Test-Path $OutFile) { Remove-Item $OutFile -Force }
    $doc = [DocumentFormat.OpenXml.Packaging.PresentationDocument]::Create($OutFile, [DocumentFormat.OpenXml.PresentationDocumentType]::Presentation)
    $presPart = $doc.AddPresentationPart()
    $presPart.Presentation = New-ScoutEl "$Script:PresNs.Presentation"

    function New-ScoutEmptyShapeTree {
        $tree = New-ScoutEl "$Script:PresNs.ShapeTree"
        $nvGrp = New-ScoutEl "$Script:PresNs.NonVisualGroupShapeProperties"
        $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
        $cnv.Id = [uint32]1
        $cnv.Name = ''
        $nvGrp.Append($cnv)
        $nvGrp.Append((New-ScoutEl "$Script:PresNs.NonVisualGroupShapeDrawingProperties"))
        $nvGrp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
        $tree.Append($nvGrp)
        $tree.Append((New-ScoutEl "$Script:PresNs.GroupShapeProperties"))
        return , $tree
    }

    # ---- Slide master ----
    $masterPart = New-ScoutPart $presPart ([DocumentFormat.OpenXml.Packaging.SlideMasterPart]) 'rIdMaster1'
    $master = New-ScoutEl "$Script:PresNs.SlideMaster"
    $mcsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $mcsd.Append((New-ScoutEmptyShapeTree))
    $master.Append($mcsd)

    $cm = New-ScoutEl "$Script:PresNs.ColorMap"
    $cm.Background1 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Light1
    $cm.Text1 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Dark1
    $cm.Background2 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Light2
    $cm.Text2 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Dark2
    $cm.Accent1 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent1
    $cm.Accent2 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent2
    $cm.Accent3 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent3
    $cm.Accent4 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent4
    $cm.Accent5 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent5
    $cm.Accent6 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent6
    $cm.Hyperlink = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Hyperlink
    $cm.FollowedHyperlink = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::FollowedHyperlink
    $master.Append($cm)

    $sliList = New-ScoutEl "$Script:PresNs.SlideLayoutIdList"
    $sliTitle = New-ScoutEl "$Script:PresNs.SlideLayoutId"; $sliTitle.Id = [uint32]2147483649; $sliTitle.RelationshipId = 'rIdLayoutTitle'
    $sliContent = New-ScoutEl "$Script:PresNs.SlideLayoutId"; $sliContent.Id = [uint32]2147483650; $sliContent.RelationshipId = 'rIdLayoutContent'
    $sliList.Append($sliTitle); $sliList.Append($sliContent)
    $master.Append($sliList)

    $ts = New-ScoutEl "$Script:PresNs.TextStyles"
    $ts.Append((New-ScoutEl "$Script:PresNs.TitleStyle"))
    $ts.Append((New-ScoutEl "$Script:PresNs.BodyStyle"))
    $ts.Append((New-ScoutEl "$Script:PresNs.OtherStyle"))
    $master.Append($ts)
    $masterPart.SlideMaster = $master

    # ---- Slide layouts (title + content) ----
    $layoutTitlePart = New-ScoutPart $masterPart ([DocumentFormat.OpenXml.Packaging.SlideLayoutPart]) 'rIdLayoutTitle'
    $layoutTitle = New-ScoutEl "$Script:PresNs.SlideLayout"
    $layoutTitle.Type = [DocumentFormat.OpenXml.Presentation.SlideLayoutValues]::Title
    $ltCsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $ltCsd.Name = 'Azure Scout Title'
    $ltCsd.Append((New-ScoutEmptyShapeTree))
    $layoutTitle.Append($ltCsd)
    $ltCmo = New-ScoutEl "$Script:PresNs.ColorMapOverride"; $ltCmo.Append((New-ScoutEl "$Script:DrawNs.MasterColorMapping"))
    $layoutTitle.Append($ltCmo)
    $layoutTitlePart.SlideLayout = $layoutTitle

    $layoutContentPart = New-ScoutPart $masterPart ([DocumentFormat.OpenXml.Packaging.SlideLayoutPart]) 'rIdLayoutContent'
    $layoutContent = New-ScoutEl "$Script:PresNs.SlideLayout"
    $layoutContent.Type = [DocumentFormat.OpenXml.Presentation.SlideLayoutValues]::Text
    $lcCsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $lcCsd.Name = 'Azure Scout Content'
    $lcCsd.Append((New-ScoutEmptyShapeTree))
    $layoutContent.Append($lcCsd)
    $lcCmo = New-ScoutEl "$Script:PresNs.ColorMapOverride"; $lcCmo.Append((New-ScoutEl "$Script:DrawNs.MasterColorMapping"))
    $layoutContent.Append($lcCmo)
    $layoutContentPart.SlideLayout = $layoutContent

    # ---- Theme (navy/steel/gold corporate palette, matches report.html.template) ----
    $themePart = New-ScoutPart $masterPart ([DocumentFormat.OpenXml.Packaging.ThemePart]) 'rIdTheme1'
    $theme = New-ScoutEl "$Script:DrawNs.Theme"
    $theme.Name = 'Azure Scout'

    $colorScheme = New-ScoutEl "$Script:DrawNs.ColorScheme"
    $colorScheme.Name = 'Azure Scout'
    $dk1 = New-ScoutEl "$Script:DrawNs.Dark1Color"
    $sysDk = New-ScoutEl "$Script:DrawNs.SystemColor"; $sysDk.Val = [DocumentFormat.OpenXml.Drawing.SystemColorValues]::WindowText; $sysDk.LastColor = $Script:Ink
    $dk1.Append($sysDk)
    $lt1 = New-ScoutEl "$Script:DrawNs.Light1Color"
    $sysLt = New-ScoutEl "$Script:DrawNs.SystemColor"; $sysLt.Val = [DocumentFormat.OpenXml.Drawing.SystemColorValues]::Window; $sysLt.LastColor = $Script:Paper
    $lt1.Append($sysLt)
    $dk2 = New-ScoutEl "$Script:DrawNs.Dark2Color"; $dk2.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex"))
    $dk2.RgbColorModelHex.Val = $Script:Navy
    $lt2 = New-ScoutEl "$Script:DrawNs.Light2Color"; $lt2.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex"))
    $lt2.RgbColorModelHex.Val = $Script:Mist
    $a1 = New-ScoutEl "$Script:DrawNs.Accent1Color"; $a1.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a1.RgbColorModelHex.Val = $Script:Navy
    $a2 = New-ScoutEl "$Script:DrawNs.Accent2Color"; $a2.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a2.RgbColorModelHex.Val = $Script:Steel
    $a3 = New-ScoutEl "$Script:DrawNs.Accent3Color"; $a3.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a3.RgbColorModelHex.Val = $Script:Green
    $a4 = New-ScoutEl "$Script:DrawNs.Accent4Color"; $a4.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a4.RgbColorModelHex.Val = $Script:Gold
    $a5 = New-ScoutEl "$Script:DrawNs.Accent5Color"; $a5.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a5.RgbColorModelHex.Val = $Script:Red
    $a6 = New-ScoutEl "$Script:DrawNs.Accent6Color"; $a6.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a6.RgbColorModelHex.Val = $Script:Gray
    $hl = New-ScoutEl "$Script:DrawNs.Hyperlink"; $hl.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $hl.RgbColorModelHex.Val = $Script:Steel
    $fhl = New-ScoutEl "$Script:DrawNs.FollowedHyperlinkColor"; $fhl.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $fhl.RgbColorModelHex.Val = $Script:Navy
    $colorScheme.Append($dk1, $lt1, $dk2, $lt2, $a1, $a2, $a3, $a4, $a5, $a6, $hl, $fhl)

    $fontScheme = New-ScoutEl "$Script:DrawNs.FontScheme"
    $fontScheme.Name = 'Azure Scout'
    $majorFont = New-ScoutEl "$Script:DrawNs.MajorFont"
    $majorLatin = New-ScoutEl "$Script:DrawNs.LatinFont"; $majorLatin.Typeface = 'Segoe UI Semibold'
    $majorFont.Append($majorLatin); $majorFont.Append((New-ScoutEl "$Script:DrawNs.EastAsianFont")); $majorFont.Append((New-ScoutEl "$Script:DrawNs.ComplexScriptFont"))
    $minorFont = New-ScoutEl "$Script:DrawNs.MinorFont"
    $minorLatin = New-ScoutEl "$Script:DrawNs.LatinFont"; $minorLatin.Typeface = 'Segoe UI'
    $minorFont.Append($minorLatin); $minorFont.Append((New-ScoutEl "$Script:DrawNs.EastAsianFont")); $minorFont.Append((New-ScoutEl "$Script:DrawNs.ComplexScriptFont"))
    $fontScheme.Append($majorFont, $minorFont)

    $fmtScheme = New-ScoutEl "$Script:DrawNs.FormatScheme"
    $fmtScheme.Name = 'Azure Scout'
    $fillList = New-ScoutEl "$Script:DrawNs.FillStyleList"
    1..3 | ForEach-Object { $s = New-ScoutEl "$Script:DrawNs.SolidFill"; $s.Append((New-ScoutPhColor)); $fillList.Append($s) }
    $lineList = New-ScoutEl "$Script:DrawNs.LineStyleList"
    1..3 | ForEach-Object {
        $ol = New-ScoutEl "$Script:DrawNs.Outline"; $ol.Width = 9525
        $olFill = New-ScoutEl "$Script:DrawNs.SolidFill"; $olFill.Append((New-ScoutPhColor))
        $ol.Append($olFill)
        $lineList.Append($ol)
    }
    $effList = New-ScoutEl "$Script:DrawNs.EffectStyleList"
    1..3 | ForEach-Object { $es = New-ScoutEl "$Script:DrawNs.EffectStyle"; $es.Append((New-ScoutEl "$Script:DrawNs.EffectList")); $effList.Append($es) }
    $bgList = New-ScoutEl "$Script:DrawNs.BackgroundFillStyleList"
    1..3 | ForEach-Object { $s = New-ScoutEl "$Script:DrawNs.SolidFill"; $s.Append((New-ScoutPhColor)); $bgList.Append($s) }
    $fmtScheme.Append($fillList, $lineList, $effList, $bgList)

    $themeElements = New-ScoutEl "$Script:DrawNs.ThemeElements"
    $themeElements.Append($colorScheme, $fontScheme, $fmtScheme)
    $theme.Append($themeElements)
    $theme.Append((New-ScoutEl "$Script:DrawNs.ObjectDefaults"))
    $theme.Append((New-ScoutEl "$Script:DrawNs.ExtraColorSchemeList"))
    $themePart.Theme = $theme

    return [pscustomobject]@{
        Doc                = $doc
        PresPart           = $presPart
        MasterPart         = $masterPart
        LayoutTitlePart    = $layoutTitlePart
        LayoutContentPart  = $layoutContentPart
        NextSlideRelIdNum  = 1
        NextSlideIdNum     = 256
    }
}

#endregion

#region Slide chrome + slide assembly

function Add-ScoutSlideChrome {
    # Appends the shared navy-bar / title / gold-underline / footer shapes
    # that make every content slide read as one consistent template.
    param($Tree, [string]$Title, [int]$PageNum, [int]$TotalPages)

    $bar = New-ScoutShape -Name 'TopBar' -X 0 -Y 0 -Cx $Script:SlideWIn -Cy 0.09 -FillHex $Script:Navy
    $Tree.Append($bar)

    $titleParas = New-ScoutList
    $titleRuns = New-ScoutList
    $titleRuns.Add((New-ScoutRun -Text $Title -SizePt 26 -Hex $Script:Navy -Bold $true -Font 'Segoe UI Semibold'))
    $titleParas.Add((New-ScoutPara -Runs $titleRuns))
    $titleShape = New-ScoutShape -Name 'SlideTitle' -X 0.55 -Y 0.26 -Cx 12.2 -Cy 0.62 -Paragraphs $titleParas
    $Tree.Append($titleShape)

    $underline = New-ScoutShape -Name 'TitleUnderline' -X 0.55 -Y 0.92 -Cx 1.1 -Cy 0.045 -FillHex $Script:Gold
    $Tree.Append($underline)

    $divider = New-ScoutShape -Name 'FooterDivider' -X 0.55 -Y 7.02 -Cx 12.23 -Cy 0.012 -FillHex $Script:Line
    $Tree.Append($divider)

    $wmParas = New-ScoutList
    $wmRuns = New-ScoutList
    $wmRuns.Add((New-ScoutRun -Text 'AZURE SCOUT' -SizePt 9 -Hex $Script:Gray -Bold $true))
    $wmParas.Add((New-ScoutPara -Runs $wmRuns))
    $wordmark = New-ScoutShape -Name 'FooterWordmark' -X 0.55 -Y 7.09 -Cx 4 -Cy 0.3 -Paragraphs $wmParas
    $Tree.Append($wordmark)

    if ($PageNum -gt 0) {
        $pgParas = New-ScoutList
        $pgRuns = New-ScoutList
        $pgRuns.Add((New-ScoutRun -Text "$PageNum / $TotalPages" -SizePt 9 -Hex $Script:Gray))
        $pgParas.Add((New-ScoutPara -Runs $pgRuns -Align 'r'))
        $pgShape = New-ScoutShape -Name 'FooterPage' -X 10.78 -Y 7.09 -Cx 2 -Cy 0.3 -Paragraphs $pgParas
        $Tree.Append($pgShape)
    }
}

function New-ScoutEmptyShapeTreeStandalone {
    # Same structure as the shell's private helper, exposed here for slide bodies.
    $tree = New-ScoutEl "$Script:PresNs.ShapeTree"
    $nvGrp = New-ScoutEl "$Script:PresNs.NonVisualGroupShapeProperties"
    $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
    $cnv.Id = [uint32]1
    $cnv.Name = ''
    $nvGrp.Append($cnv)
    $nvGrp.Append((New-ScoutEl "$Script:PresNs.NonVisualGroupShapeDrawingProperties"))
    $nvGrp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
    $tree.Append($nvGrp)
    $tree.Append((New-ScoutEl "$Script:PresNs.GroupShapeProperties"))
    return , $tree
}

function New-ScoutSlideElement {
    param($Tree)
    $slide = New-ScoutEl "$Script:PresNs.Slide"
    $scsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $scsd.Append($Tree)
    $slide.Append($scsd)
    $cmo = New-ScoutEl "$Script:PresNs.ColorMapOverride"
    $cmo.Append((New-ScoutEl "$Script:DrawNs.MasterColorMapping"))
    $slide.Append($cmo)
    return , $slide
}

function Add-ScoutSlideToDeck {
    # Creates the SlidePart for a fully-built Slide element, wires it to the
    # given layout part, and registers it in the presentation's SlideIdList.
    param($Shell, $SlideElement, $LayoutPart)
    $relId = "rIdSlide$($Shell.NextSlideRelIdNum)"
    $slidePart = New-ScoutPart $Shell.PresPart ([DocumentFormat.OpenXml.Packaging.SlidePart]) $relId
    $slidePart.Slide = $SlideElement
    $null = $slidePart.AddPart($LayoutPart)

    if (-not $Shell.PSObject.Properties['SlideIdList']) {
        $Shell | Add-Member -NotePropertyName SlideIdList -NotePropertyValue (New-ScoutEl "$Script:PresNs.SlideIdList")
    }
    $sid = New-ScoutEl "$Script:PresNs.SlideId"
    $sid.Id = [uint32]$Shell.NextSlideIdNum
    $sid.RelationshipId = $Shell.PresPart.GetIdOfPart($slidePart)
    $Shell.SlideIdList.Append($sid)

    $Shell.NextSlideRelIdNum++
    $Shell.NextSlideIdNum++
}

#endregion

#region Slide content builders

function New-ScoutTitleSlide {
    param([Parameter(Mandatory)]$Shell, [string]$Title, [string]$Subtitle, [string]$MetaLine)

    $tree = New-ScoutEmptyShapeTreeStandalone
    $bg = New-ScoutShape -Name 'TitleBackground' -X 0 -Y 0 -Cx $Script:SlideWIn -Cy $Script:SlideHIn -FillHex $Script:Navy
    $tree.Append($bg)

    $wmParas = New-ScoutList; $wmRuns = New-ScoutList
    $wmRuns.Add((New-ScoutRun -Text 'AZURE SCOUT' -SizePt 14 -Hex $Script:Paper -Bold $true))
    $wmParas.Add((New-ScoutPara -Runs $wmRuns))
    $tree.Append((New-ScoutShape -Name 'Wordmark' -X 0.6 -Y 0.45 -Cx 5 -Cy 0.4 -Paragraphs $wmParas))

    $accent = New-ScoutShape -Name 'TitleAccent' -X 5.67 -Y 2.45 -Cx 2 -Cy 0.05 -FillHex $Script:Gold
    $tree.Append($accent)

    $titleParas = New-ScoutList; $titleRuns = New-ScoutList
    $titleRuns.Add((New-ScoutRun -Text $Title -SizePt 40 -Hex $Script:Paper -Bold $true -Font 'Segoe UI Semibold'))
    $titleParas.Add((New-ScoutPara -Runs $titleRuns -Align 'ctr'))
    $tree.Append((New-ScoutShape -Name 'Title' -X 1 -Y 2.65 -Cx 11.33 -Cy 1.1 -Paragraphs $titleParas -Anchor 'ctr'))

    if ($Subtitle) {
        $subParas = New-ScoutList; $subRuns = New-ScoutList
        $subRuns.Add((New-ScoutRun -Text $Subtitle -SizePt 18 -Hex 'D9E4EF'))
        $subParas.Add((New-ScoutPara -Runs $subRuns -Align 'ctr'))
        $tree.Append((New-ScoutShape -Name 'Subtitle' -X 1 -Y 3.75 -Cx 11.33 -Cy 0.55 -Paragraphs $subParas -Anchor 'ctr'))
    }

    if ($MetaLine) {
        $metaParas = New-ScoutList; $metaRuns = New-ScoutList
        $metaRuns.Add((New-ScoutRun -Text $MetaLine -SizePt 13 -Hex 'AFC3D9'))
        $metaParas.Add((New-ScoutPara -Runs $metaRuns -Align 'ctr'))
        $tree.Append((New-ScoutShape -Name 'MetaLine' -X 1 -Y 4.35 -Cx 11.33 -Cy 0.5 -Paragraphs $metaParas -Anchor 'ctr'))
    }

    $footParas = New-ScoutList; $footRuns = New-ScoutList
    $footRuns.Add((New-ScoutRun -Text 'Confidential — prepared by Azure Scout' -SizePt 10 -Hex 'AFC3D9'))
    $footParas.Add((New-ScoutPara -Runs $footRuns -Align 'ctr'))
    $tree.Append((New-ScoutShape -Name 'TitleFooter' -X 1 -Y 6.85 -Cx 11.33 -Cy 0.4 -Paragraphs $footParas -Anchor 'ctr'))

    $slide = New-ScoutSlideElement -Tree $tree
    Add-ScoutSlideToDeck -Shell $Shell -SlideElement $slide -LayoutPart $Shell.LayoutTitlePart
}

function New-ScoutContentSlide {
    # $BodyShapeBuilder: scriptblock that appends body shapes onto $tree (receives $tree as arg).
    param([Parameter(Mandatory)]$Shell, [string]$Title, [int]$PageNum, [int]$TotalPages, [scriptblock]$BodyShapeBuilder)
    $tree = New-ScoutEmptyShapeTreeStandalone
    Add-ScoutSlideChrome -Tree $tree -Title $Title -PageNum $PageNum -TotalPages $TotalPages
    & $BodyShapeBuilder $tree
    $slide = New-ScoutSlideElement -Tree $tree
    Add-ScoutSlideToDeck -Shell $Shell -SlideElement $slide -LayoutPart $Shell.LayoutContentPart
}

function Add-ScoutBulletList {
    param($Tree, [double]$X, [double]$Y, [double]$Cx, [string[]]$Lines, [double]$SizePt = 15, [double]$LineGapIn = 0.5)
    $paras = New-ScoutList
    foreach ($line in $Lines) {
        $runs = New-ScoutList
        $runs.Add((New-ScoutRun -Text "• $line" -SizePt $SizePt -Hex $Script:Ink))
        $paras.Add((New-ScoutPara -Runs $runs -SpaceBeforePt 6))
    }
    $height = [math]::Max($LineGapIn * $Lines.Count, 0.4)
    $Tree.Append((New-ScoutShape -Name 'BulletList' -X $X -Y $Y -Cx $Cx -Cy $height -Paragraphs $paras))
}

function Add-ScoutScoreCard {
    param($Tree, [double]$X, [double]$Y, [double]$Cx, [double]$Cy, [string]$Label, $Score)
    $color = Get-ScoutScoreColor $Score
    $scoreText = if ($null -eq $Score) { '—' } else { "$Score" }
    $paras = New-ScoutList
    $bigRuns = New-ScoutList
    $bigRuns.Add((New-ScoutRun -Text $scoreText -SizePt 44 -Hex $Script:Paper -Bold $true -Font 'Segoe UI Semibold'))
    $paras.Add((New-ScoutPara -Runs $bigRuns -Align 'ctr'))
    $lblRuns = New-ScoutList
    $lblRuns.Add((New-ScoutRun -Text $Label -SizePt 13 -Hex $Script:Paper))
    $paras.Add((New-ScoutPara -Runs $lblRuns -Align 'ctr'))
    $Tree.Append((New-ScoutShape -Name "Card_$Label" -X $X -Y $Y -Cx $Cx -Cy $Cy -FillHex $color -Anchor 'ctr' -RadiusPct 6 -Paragraphs $paras))
}

function New-ScoutExecSummarySlide {
    param($Shell, $Frameworks, $Areas, $Gaps, $Manual, $Errors, [int]$PageNum, [int]$TotalPages)

    New-ScoutContentSlide -Shell $Shell -Title 'Executive Summary' -PageNum $PageNum -TotalPages $TotalPages -BodyShapeBuilder {
        param($tree)

        $cardW = 2.7
        $gap = 0.25
        $startX = 0.55
        $i = 0
        if (@($Frameworks).Count -eq 0) {
            Add-ScoutScoreCard -Tree $tree -X $startX -Y 1.25 -Cx $cardW -Cy 1.7 -Label 'No framework scored' -Score $null
        } else {
            foreach ($fw in @($Frameworks)) {
                $x = $startX + ($i * ($cardW + $gap))
                $label = "$(Get-ScoutProp $fw 'Framework') Alignment Score"
                Add-ScoutScoreCard -Tree $tree -X $x -Y 1.25 -Cx $cardW -Cy 1.7 -Label $label -Score (Get-ScoutProp $fw 'Score')
                $i++
            }
        }

        $areaArr = @($Areas)
        $passSum = ($areaArr | ForEach-Object { Get-ScoutProp $_ 'Pass' 0 } | Measure-Object -Sum).Sum
        $partialSum = ($areaArr | ForEach-Object { Get-ScoutProp $_ 'Partial' 0 } | Measure-Object -Sum).Sum
        $failSum = ($areaArr | ForEach-Object { Get-ScoutProp $_ 'Fail' 0 } | Measure-Object -Sum).Sum
        $manualCount = @($Manual).Count
        $errorCount = @($Errors).Count
        $highGaps = (@($Gaps) | Where-Object { (Get-ScoutSeverityLabel (Get-ScoutProp $_ 'Severity')) -eq 'HIGH' } | Measure-Object).Count

        $lines = @(
            "Areas assessed: $($areaArr.Count)"
            "Rules evaluated — Pass: $passSum, Partial: $partialSum, Fail: $failSum" "Critical (High severity) gaps: $highGaps" "Manual review items pending: $manualCount" "Unknown/Error findings (check collector permissions): $errorCount" ) Add-ScoutBulletList -Tree $tree -X 0.55 -Y 3.5 -Cx 12.2 -Lines $lines -SizePt 15 -LineGapIn 0.5 } } function New-ScoutAreaTableSlides { param($Shell, $Areas, [ref]$PageCounter, [int]$TotalPages) # @(...) wraps the WHOLE pipeline, not just $Areas -- a Sort-Object over zero # input collapses the bare assignment to $null, and $null.Count throws # PropertyNotFoundException under Set-StrictMode -Version Latest. Same # load-bearing pattern Get-Score.ps1 documents for its own Pass/Fail counters. $rows = @(@($Areas) | Sort-Object Framework, Area) if ($rows.Count -eq 0) { return } $chunks = Split-ScoutChunks -Items $rows -Size 10 $pageOfPages = $chunks.Count $chunkIdx = 0 foreach ($chunk in $chunks) { $chunkIdx++ $title = if ($pageOfPages -gt 1) { "Area Score Breakdown ($chunkIdx/$pageOfPages)" } else { 'Area Score Breakdown' }
        $capturedChunk = $chunk
        New-ScoutContentSlide -Shell $Shell -Title $title -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            $colW = @(1.6, 3.5, 1.0, 0.9, 0.9, 0.9, 1.0)
            $rowsList = New-ScoutList
            $headerCells = New-ScoutList
            foreach ($h in 'Framework', 'Area', 'Score', 'Pass', 'Partial', 'Fail', 'Manual') {
                $headerCells.Add((New-ScoutTableCell -Text $h -SizePt 12 -Hex $Script:Paper -Bold $true -FillHex $Script:Navy))
            }
            $rowsList.Add((New-ScoutTableRow -HeightIn 0.38 -Cells $headerCells))
 
            $r = 0
            foreach ($area in $capturedChunk) {
                $r++
                $bg = if ($r % 2 -eq 0) { $Script:Mist } else { $Script:Paper }
                $score = Get-ScoutProp $area 'Score'
                $scoreColor = Get-ScoutScoreColor $score
                $scoreText = if ($null -eq $score) { '—
' } else { "$score" }
                $cells = New-ScoutList
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area '
Framework')" -Hex $Script:Ink -FillHex $bg)) $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Area')" -Hex $Script:Ink -FillHex $bg -Align 'l')) $cells.Add((New-ScoutTableCell -Text $scoreText -Bold $true -Hex $scoreColor -FillHex $bg)) $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Pass' 0)" -Hex $Script:Ink -FillHex $bg)) $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Partial' 0)" -Hex $Script:Ink -FillHex $bg)) $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Fail' 0)" -Hex $Script:Ink -FillHex $bg)) $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Manual' 0)" -Hex $Script:Ink -FillHex $bg)) $rowsList.Add((New-ScoutTableRow -HeightIn 0.32 -Cells $cells)) } $tree.Append((New-ScoutTable -Name 'AreaTable' -X 0.55 -Y 1.25 -ColWidthsIn $colW -Rows $rowsList)) } $PageCounter.Value++ } } function New-ScoutGapsSlides { param($Shell, $Gaps, [ref]$PageCounter, [int]$TotalPages, [int]$MaxGaps = 15) # AB#5089: defensive re-sort at render time too — null/unrecognized severity # sorts LAST — even if the caller passes gaps that were never run through # Get-Score's own sort. Both @(...) wraps are load-bearing: a Sort-Object/
    # Select-Object over zero input collapses the bare assignment to $null, and
    # $null.Count throws PropertyNotFoundException under Set-StrictMode -Version Latest.
    $sorted = @(@($Gaps) | Sort-Object @{ Expression = { Get-ScoutSeverityRank (Get-ScoutProp $_ 'Severity') } }, Area)
    $top = @($sorted | Select-Object -First $MaxGaps)

    if ($top.Count -eq 0) {
        New-ScoutContentSlide -Shell $Shell -Title 'Prioritized Gaps' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            Add-ScoutBulletList -Tree $tree -X 0.55 -Y 1.6 -Cx 12.2 -Lines @('No prioritized gaps — all assessed rules are passing.') -SizePt 16
        }
        $PageCounter.Value++
        return
    }

    $chunks = Split-ScoutChunks -Items $top -Size 10
    $pageOfPages = $chunks.Count
    $chunkIdx = 0
    foreach ($chunk in $chunks) {
        $chunkIdx++
        $title = if ($pageOfPages -gt 1) { "Prioritized Gaps (Top $($top.Count)) — $chunkIdx/$pageOfPages" } else { "Prioritized Gaps (Top $($top.Count))" }
        $capturedChunk = $chunk
        New-ScoutContentSlide -Shell $Shell -Title $title -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            $colW = @(1.3, 2.6, 7.0)
            $rowsList = New-ScoutList
            $headerCells = New-ScoutList
            foreach ($h in 'Severity', 'Area', 'Gap') {
                $headerCells.Add((New-ScoutTableCell -Text $h -SizePt 12 -Hex $Script:Paper -Bold $true -FillHex $Script:Navy))
            }
            $rowsList.Add((New-ScoutTableRow -HeightIn 0.38 -Cells $headerCells))
 
            $r = 0
            foreach ($gap in $capturedChunk) {
                $r++
                $bg = if ($r % 2 -eq 0) { $Script:Mist } else { $Script:Paper }
                $sevLabel = Get-ScoutSeverityLabel (Get-ScoutProp $gap 'Severity')
                $sevColor = Get-ScoutSeverityColor (Get-ScoutProp $gap 'Severity')
                $cells = New-ScoutList
                $cells.Add((New-ScoutTableCell -Text $sevLabel -Bold $true -Hex $Script:Paper -FillHex $sevColor))
                $cells.Add((New-ScoutTableCell -Text "
$(Get-ScoutProp $gap 'Area')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
                $cells.Add((New-ScoutTableCell -Text "
$(Get-ScoutProp $gap 'Title')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
                $rowsList.Add((New-ScoutTableRow -HeightIn 0.4 -Cells $cells))
            }
 
            $tree.Append((New-ScoutTable -Name 'GapsTable' -X 0.55 -Y 1.25 -ColWidthsIn $colW -Rows $rowsList))
        }
        $PageCounter.Value++
    }
}
 
function New-ScoutManualSlide {
    param($Shell, $Manual, [ref]$PageCounter, [int]$TotalPages, [int]$MaxRows = 12)
 
    $items = @($Manual)
    if ($items.Count -eq 0) {
        New-ScoutContentSlide -Shell $Shell -Title 'Manual Review Worklist' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            Add-ScoutBulletList -Tree $tree -X 0.55 -Y 1.6 -Cx 12.2 -Lines @('No manual review items —
 full automated coverage for the selected assessment(s).') -SizePt 16
        }
        $PageCounter.Value++
        return
    }
 
    # @() keeps $shown a real array regardless of how many items Select-Object
    # -First actually returns -- Select-Object over exactly one input otherwise
    # collapses the bare assignment to a scalar, which is the same fragile
    # single-item-collapse shape the zero-item $null collapse guarded elsewhere
    # in this file comes from ($items.Count -eq 0 is already ruled out above).
    $shown = @($items | Select-Object -First $MaxRows)
    $truncated = $items.Count - $shown.Count
    New-ScoutContentSlide -Shell $Shell -Title '
Manual Review Worklist' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder { param($tree) $colW = @(3.5, 8.5) $rowsList = New-ScoutList $headerCells = New-ScoutList foreach ($h in 'Area', 'Item') { $headerCells.Add((New-ScoutTableCell -Text $h -SizePt 12 -Hex $Script:Paper -Bold $true -FillHex $Script:Navy)) } $rowsList.Add((New-ScoutTableRow -HeightIn 0.38 -Cells $headerCells)) $r = 0 foreach ($m in $shown) { $r++ $bg = if ($r % 2 -eq 0) { $Script:Mist } else { $Script:Paper } $cells = New-ScoutList $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $m 'Area')" -Hex $Script:Ink -FillHex $bg -Align 'l')) $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $m 'Title')" -Hex $Script:Ink -FillHex $bg -Align 'l')) $rowsList.Add((New-ScoutTableRow -HeightIn 0.36 -Cells $cells)) } $tree.Append((New-ScoutTable -Name 'ManualTable' -X 0.55 -Y 1.25 -ColWidthsIn $colW -Rows $rowsList)) if ($truncated -gt 0) { Add-ScoutBulletList -Tree $tree -X 0.55 -Y 6.55 -Cx 12.2 -Lines @("+$truncated more not shown — see the evidence pack (Excel tier) for the full list.") -SizePt 11 -LineGapIn 0.3 } } $PageCounter.Value++ } function New-ScoutNextStepsSlide { param($Shell, [ref]$PageCounter, [int]$TotalPages) New-ScoutContentSlide -Shell $Shell -Title 'Recommended Next Steps' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder { param($tree) $lines = @( 'Prioritize remediation of High severity gaps identified in this report.' 'Assign owners and target dates for each Fail finding using the evidence pack (Excel tier).'
            '
Complete outstanding Manual review items with the platform/security team.' 'Re-run Azure Scout after remediation to track score improvement over time.' 'Revisit Unknown/Error findings â€” these usually indicate a collector permission or query issue, not a compliance failure.' ) Add-ScoutBulletList -Tree $tree -X 0.55 -Y 1.5 -Cx 12.2 -Lines $lines -SizePt 16 -LineGapIn 0.7 } $PageCounter.Value++ } #endregion function Export-Pptx { <# .SYNOPSIS Renders the executive PowerPoint deck for a scored Findings object. .PARAMETER Findings The scored object returned by Get-Score (GeneratedOn/Frameworks/Areas/ Gaps/Manual/Errors/Findings). .PARAMETER Collect Optional — the raw collect object (same one Export-Html/-Excel/-PowerBi
        already receive from Export-Report.ps1). Used only to surface scope /
        management-group context on the title slide when present; the deck
        renders fine without it (Collect carries no tenant field today â€” see src/collect/Invoke-Collect.ps1 — so the title slide degrades gracefully
        to date + branding only when Collect is absent or _meta is empty).

    .PARAMETER OutputPath
        Directory the rendered assessment_deck.pptx is written into.
    #>
    param($Findings, $Collect, [string] $OutputPath)

    Import-ScoutOpenXmlAssembly

    $outFile = Join-Path $OutputPath 'assessment_deck.pptx'
    New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction SilentlyContinue | Out-Null

    $frameworks = @(Get-ScoutProp $Findings 'Frameworks')
    $areas = @(Get-ScoutProp $Findings 'Areas')
    $gaps = @(Get-ScoutProp $Findings 'Gaps')
    $manual = @(Get-ScoutProp $Findings 'Manual')
    $errors = @(Get-ScoutProp $Findings 'Errors')
    $generatedOn = Get-ScoutProp $Findings 'GeneratedOn'
    $generatedText = if ($generatedOn) {
        try { ([datetime]$generatedOn).ToString('yyyy-MM-dd') } catch { "$generatedOn" }
    } else { (Get-Date).ToString('yyyy-MM-dd') }

    $scope = Get-ScoutProp (Get-ScoutProp $Collect '_meta') 'scope'
    $mgId = Get-ScoutProp (Get-ScoutProp $Collect '_meta') 'managementGroupId'
    $metaParts = New-Object System.Collections.Generic.List[string]
    $metaParts.Add("Generated $generatedText")
    if ($scope) { $metaParts.Add("Scope: $scope") }
    if ($mgId) { $metaParts.Add("Management Group: $mgId") }
    $metaLine = [string]::Join(' · ', $metaParts)

    # Slide count plan (used for the "n / total" footer):
    # 1 title + 1 summary + area-table pages + gap pages (>=1) + 1 manual + 1 next-steps
    $areaPages = if (@($areas).Count -gt 0) { [Math]::Ceiling(@($areas).Count / 10.0) } else { 0 }
    # @() wraps the WHOLE pipeline -- Select-Object -First over zero input
    # collapses the bare assignment to $null, and $null.Count throws under
    # Set-StrictMode -Version Latest.
    $gapCandidates = @(@($gaps) | Select-Object -First 15)
    $gapPages = if ($gapCandidates.Count -gt 0) { [Math]::Ceiling($gapCandidates.Count / 10.0) } else { 1 }
    $totalPages = 1 + 1 + $areaPages + $gapPages + 1 + 1

    $shell = New-ScoutDeckShell -OutFile $outFile

    New-ScoutTitleSlide -Shell $shell -Title 'Azure Landing Zone Assessment' `
        -Subtitle 'Executive Assessment — CAF & WAF Alignment' -MetaLine $metaLine

    $page = 2
    New-ScoutExecSummarySlide -Shell $shell -Frameworks $frameworks -Areas $areas -Gaps $gaps -Manual $manual -Errors $errors -PageNum $page -TotalPages $totalPages
    $page++

    $pageRef = [ref]$page
    New-ScoutAreaTableSlides -Shell $shell -Areas $areas -PageCounter $pageRef -TotalPages $totalPages
    New-ScoutGapsSlides -Shell $shell -Gaps $gaps -PageCounter $pageRef -TotalPages $totalPages
    New-ScoutManualSlide -Shell $shell -Manual $manual -PageCounter $pageRef -TotalPages $totalPages
    New-ScoutNextStepsSlide -Shell $shell -PageCounter $pageRef -TotalPages $totalPages

    # ---- wire the presentation-level lists and save ----
    $slideMasterIdList = New-ScoutEl "$Script:PresNs.SlideMasterIdList"
    $smId = New-ScoutEl "$Script:PresNs.SlideMasterId"
    $smId.Id = [uint32]2147483648
    $smId.RelationshipId = $shell.PresPart.GetIdOfPart($shell.MasterPart)
    $slideMasterIdList.Append($smId)

    $slideSize = New-ScoutEl "$Script:PresNs.SlideSize"
    $slideSize.Cx = [int32](ScoutEmu32 $Script:SlideWIn)
    $slideSize.Cy = [int32](ScoutEmu32 $Script:SlideHIn)
    $slideSize.Type = [DocumentFormat.OpenXml.Presentation.SlideSizeValues]::Screen16x9

    $notesSize = New-ScoutEl "$Script:PresNs.NotesSize"
    $notesSize.Cx = [int32](ScoutEmu32 7.5)
    $notesSize.Cy = [int32](ScoutEmu32 10)

    $shell.PresPart.Presentation.Append($slideMasterIdList, $shell.SlideIdList, $slideSize, $notesSize, (New-ScoutEl "$Script:PresNs.DefaultTextStyle"))
    $shell.PresPart.Presentation.Save()
    $shell.Doc.Dispose()

    return $outFile
}