.claude/worktrees/agent-a417f0bd77de7f13b/src/report/renderers/Export-Word.ps1

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

<#
.SYNOPSIS
    Auto-assemble a Word (.docx) assessment report from scored findings via the
    OpenXML SDK (DocumentFormat.OpenXml) — no Word install required.
 
.DESCRIPTION
    Renders a print/share-friendly Word document directly from the scored
    Findings object (the output of Get-Score: GeneratedOn/Frameworks/Areas/
    Gaps/Manual/Errors/Findings) plus the raw Collect object (only its optional
    _meta.scope / _meta.managementGroupId are read, mirroring Export-Pptx).
    Every part of the .docx OPC package (document body, section properties,
    tables) is constructed programmatically with the OpenXML SDK
    (DocumentFormat.OpenXml.Wordprocessing) — the same accepted design used by
    Export-Pptx.ps1 for the executive deck (AB#5044).
 
    Section inventory:
      1. Cover — title, subtitle, generated date/scope/mgmt-group
      2. Executive Summary — framework score table + rollup counts
      3. Findings by Area — one heading + findings table per assessed area
      4. Prioritized Gaps — Severity/Area/Gap table, sorted worst-first,
                             capped at top $Script:ScoutDocxMaxGaps rows
      5. Manual Review — outstanding manual-review worklist, capped at
                             $Script:ScoutDocxMaxManual rows
 
    ASSEMBLY ACQUISITION: reuses the exact acquire-once-and-cache pattern
    Export-Pptx.ps1 established (Import-ScoutOpenXmlAssembly) — a throwaway
    dotnet-build csproj that pulls DocumentFormat.OpenXml from NuGet on first
    use and caches the DLLs under output/.tools/openxml/<version>. This file
    defines its own copy (Import-ScoutDocxOpenXmlAssembly) rather than calling
    Export-Pptx.ps1's function directly, so this renderer stays fully
    self-contained and loadable on its own (every existing renderer test
    harness in tests/ dot-sources only the one renderer .ps1 file it needs) —
    but it deliberately points at the SAME cache directory and version pin, so
    a prior Export-Pptx run (or Pptx Pester run) that already populated the
    cache means this renderer's first use costs nothing extra: Wordprocessing
    types live in the very same DocumentFormat.OpenXml.dll the deck renderer
    already downloaded.
 
.NOTES
    Tracks ADO Story AB#333.
 
    OpenXML SDK GOTCHA (also documented in Export-Pptx.ps1): every
    OpenXmlElement implements IEnumerable<OpenXmlElement> over its own
    children, and PowerShell's function-output pipeline auto-enumerates any
    IEnumerable it sees. 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. The same is true of a plain
    System.Collections.Generic.List[object] (also IEnumerable), so
    New-ScoutDocxList follows the same pattern. Do not remove the commas.
 
    Non-fatal on failure: the whole render is wrapped in try/catch. If
    anything throws (a broken OpenXML acquire, a malformed Findings object,
    etc.) this writes a self-contained "assessment_word_fallback.html" next
    to where the .docx would have gone (mirroring Export-Pdf.ps1's
    Export-ScoutPdfHtmlFallback pattern) instead of throwing and aborting the
    whole multi-renderer Export-Report loop in Invoke-ScoutAssessment.ps1.
#>


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

# Pinned so every run resolves the exact same OpenXML SDK build; bump deliberately.
# Deliberately the same version Export-Pptx.ps1 pins — see this file's header.
$Script:ScoutDocxOpenXmlVersion = '3.0.2'

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

    # Idempotent within a process — a prior Export-Pptx (or Export-Word) call
    # 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:ScoutDocxOpenXmlVersion
    $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-Word: DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) 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:ScoutDocxOpenXmlVersion)" />
  </ItemGroup>
</Project>
"@
 | Out-File -FilePath $tempProj -Encoding utf8

        Write-Host "[Export-Word] Acquiring DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) 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-Word: could not acquire DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) (offline, and nothing cached at '$cacheDir'?). dotnet build exit code $exitCode.`n$($buildOutput -join "`n")" } Write-Host "[Export-Word] DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) cached at $cacheDir" -ForegroundColor Green } foreach ($dll in $requiredDlls) { Add-Type -Path (Join-Path $cacheDir $dll) -ErrorAction Stop } } #endregion #region Low-level OpenXML element helpers $Script:ScoutDocxWNs = 'DocumentFormat.OpenXml.Wordprocessing' function New-ScoutDocxEl { param([Parameter(Mandatory)][string]$TypeName) $o = New-Object -TypeName $TypeName return , $o } function New-ScoutDocxList { return , ([System.Collections.Generic.List[object]]::new()) } function ScoutDocxDxa { # Twentieths of a point ("dxa") — the unit w:tblGrid/w:tcW/w:pgSz/w:pgMar all use.
    # 1440 dxa per inch.
    param([double]$Inches)
    return [int64][math]::Round($Inches * 1440)
}

#endregion

#region Palette (mirrors Export-Pptx.ps1's navy/steel/gold corporate palette)

$Script:ScoutDocxNavy = '1F4E78'
$Script:ScoutDocxSteel = '2E75B6'
$Script:ScoutDocxGreen = '2E7D32'
$Script:ScoutDocxGold = 'B8860B'
$Script:ScoutDocxRed = 'B00020'
$Script:ScoutDocxInk = '1A1A1A'
$Script:ScoutDocxPaper = 'FFFFFF'
$Script:ScoutDocxMist = 'F6F9FD'
$Script:ScoutDocxLine = 'D9D9D9'
$Script:ScoutDocxGray = '595959'

# Bounds on the doc's longer, unbounded-in-theory lists — a document naturally
# paginates (unlike a slide deck), so these are generous, but still finite so a
# pathological Findings object (thousands of gaps) can't produce a runaway render.
$Script:ScoutDocxMaxGaps = 50
$Script:ScoutDocxMaxManual = 100

#endregion

#region Paragraph / run helpers

function New-ScoutDocxRun {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [double]$SizePt = 11,
        [string]$Hex = $Script:ScoutDocxInk,
        [bool]$Bold = $false,
        [bool]$Italic = $false,
        [string]$Font = 'Segoe UI'
    )
    $run = New-ScoutDocxEl "$Script:ScoutDocxWNs.Run"
    $rPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.RunProperties"
    $rFonts = New-ScoutDocxEl "$Script:ScoutDocxWNs.RunFonts"
    $rFonts.Ascii = $Font
    $rFonts.HighAnsi = $Font
    $rPr.Append($rFonts)
    if ($Bold) { $rPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.Bold")) }
    if ($Italic) { $rPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.Italic")) }
    # CT_RPr child order (ECMA-376 §17.3.2.30) requires color BEFORE sz/szCs —
    # putting FontSize first (as an earlier draft of this file did) validates
    # as a schema error ("unexpected child element w:color") even though Word
    # itself tolerates it; keep this order to stay strictly schema-valid.
    $color = New-ScoutDocxEl "$Script:ScoutDocxWNs.Color"
    $color.Val = $Hex
    $rPr.Append($color)
    $sz = New-ScoutDocxEl "$Script:ScoutDocxWNs.FontSize"
    $sz.Val = "$([int][math]::Round($SizePt * 2))"
    $rPr.Append($sz)
    $run.Append($rPr)
    $t = New-ScoutDocxEl "$Script:ScoutDocxWNs.Text"
    $t.Text = $Text
    $t.Space = [DocumentFormat.OpenXml.SpaceProcessingModeValues]::Preserve
    $run.Append($t)
    return , $run
}

function New-ScoutDocxBreakRun {
    # A page-break run — appended as its own run inside the last paragraph of a
    # section so the next content starts on a fresh page.
    $run = New-ScoutDocxEl "$Script:ScoutDocxWNs.Run"
    $br = New-ScoutDocxEl "$Script:ScoutDocxWNs.Break"
    $br.Type = [DocumentFormat.OpenXml.Wordprocessing.BreakValues]::Page
    $run.Append($br)
    return , $run
}

function New-ScoutDocxPara {
    param(
        $Runs,
        [string]$Align = $null,
        [double]$SpaceBeforePt = 0,
        [double]$SpaceAfterPt = 6,
        [bool]$KeepNext = $false
    )
    $p = New-ScoutDocxEl "$Script:ScoutDocxWNs.Paragraph"
    $pPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.ParagraphProperties"
    $any = $false
    # CT_PPrBase child order (ECMA-376 §17.3.1.26) is keepNext, then spacing,
    # then jc — appending Justification first (as an earlier draft did)
    # validates as a schema error ("unexpected child element w:keepNext"/
    # "w:spacing" showing up after w:jc). Keep this order.
    if ($KeepNext) {
        $pPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.KeepNext"))
        $any = $true
    }
    if ($SpaceBeforePt -gt 0 -or $SpaceAfterPt -gt 0) {
        $spacing = New-ScoutDocxEl "$Script:ScoutDocxWNs.SpacingBetweenLines"
        if ($SpaceBeforePt -gt 0) { $spacing.Before = "$([int][math]::Round($SpaceBeforePt * 20))" }
        if ($SpaceAfterPt -gt 0) { $spacing.After = "$([int][math]::Round($SpaceAfterPt * 20))" }
        $pPr.Append($spacing)
        $any = $true
    }
    if ($Align) {
        $jc = New-ScoutDocxEl "$Script:ScoutDocxWNs.Justification"
        $jc.Val = switch ($Align) {
            'center' { [DocumentFormat.OpenXml.Wordprocessing.JustificationValues]::Center }
            'right' { [DocumentFormat.OpenXml.Wordprocessing.JustificationValues]::Right }
            default { [DocumentFormat.OpenXml.Wordprocessing.JustificationValues]::Left }
        }
        $pPr.Append($jc)
        $any = $true
    }
    if ($any) { $p.Append($pPr) }
    foreach ($r in $Runs) { $p.Append($r) }
    return , $p
}

function Add-ScoutDocxHeading {
    param($Body, [Parameter(Mandatory)][string]$Text, [int]$Level = 1)
    $sizePt = switch ($Level) { 1 { 22 } 2 { 16 } default { 13 } }
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxRun -Text $Text -SizePt $sizePt -Hex $Script:ScoutDocxNavy -Bold $true -Font 'Segoe UI Semibold'))
    $spaceBefore = if ($Level -eq 1) { 12 } else { 8 }
    $Body.Append((New-ScoutDocxPara -Runs $runs -SpaceBeforePt $spaceBefore -SpaceAfterPt 6 -KeepNext $true))
}

function Add-ScoutDocxParagraph {
    param($Body, [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [double]$SizePt = 11, [string]$Hex = $Script:ScoutDocxInk, [bool]$Italic = $false, [string]$Align = $null)
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxRun -Text $Text -SizePt $SizePt -Hex $Hex -Italic $Italic))
    $Body.Append((New-ScoutDocxPara -Runs $runs -Align $Align))
}

function Add-ScoutDocxPageBreak {
    param($Body)
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxBreakRun))
    $p = New-ScoutDocxEl "$Script:ScoutDocxWNs.Paragraph"
    foreach ($r in $runs) { $p.Append($r) }
    $Body.Append($p)
}

#endregion

#region Table helpers

function New-ScoutDocxShading {
    param([Parameter(Mandatory)][string]$Hex)
    $sh = New-ScoutDocxEl "$Script:ScoutDocxWNs.Shading"
    $sh.Val = [DocumentFormat.OpenXml.Wordprocessing.ShadingPatternValues]::Clear
    $sh.Color = 'auto'
    $sh.Fill = $Hex
    return , $sh
}

function New-ScoutDocxCell {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][double]$WidthIn,
        [double]$SizePt = 10,
        [string]$Hex = $Script:ScoutDocxInk,
        [bool]$Bold = $false,
        [string]$FillHex = $null,
        [string]$Align = $null
    )
    $tc = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCell"
    $tcPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCellProperties"
    $tcW = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCellWidth"
    $tcW.Width = "$(ScoutDocxDxa $WidthIn)"
    $tcW.Type = [DocumentFormat.OpenXml.Wordprocessing.TableWidthUnitValues]::Dxa
    $tcPr.Append($tcW)
    if ($FillHex) { $tcPr.Append((New-ScoutDocxShading -Hex $FillHex)) }
    $vAlign = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCellVerticalAlignment"
    $vAlign.Val = [DocumentFormat.OpenXml.Wordprocessing.TableVerticalAlignmentValues]::Center
    $tcPr.Append($vAlign)
    $tc.Append($tcPr)
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxRun -Text $Text -SizePt $SizePt -Hex $Hex -Bold $Bold))
    $tc.Append((New-ScoutDocxPara -Runs $runs -Align $Align -SpaceAfterPt 0))
    return , $tc
}

function New-ScoutDocxRow {
    param($Cells, [bool]$Header = $false)
    $tr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableRow"
    if ($Header) {
        $trPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableRowProperties"
        # AB#394-style header repeat (the same idea Export-Pdf.ps1 hand-rolls for
        # its findings table): w:tblHeader marks this row to repeat on every page
        # a long table spills onto, without duplicating it in the source content.
        $trPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.TableHeader"))
        $tr.Append($trPr)
    }
    foreach ($c in $Cells) { $tr.Append($c) }
    return , $tr
}

function New-ScoutDocxTable {
    param([Parameter(Mandatory)][double[]]$ColWidthsIn, [Parameter(Mandatory)]$Rows)
    $tbl = New-ScoutDocxEl "$Script:ScoutDocxWNs.Table"
    $tblPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableProperties"

    $tblW = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableWidth"
    $tblW.Width = '5000'
    $tblW.Type = [DocumentFormat.OpenXml.Wordprocessing.TableWidthUnitValues]::Pct
    $tblPr.Append($tblW)

    $borders = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableBorders"
    # CT_TblBorders child order (ECMA-376 §17.4.38) is top, left, bottom,
    # right, insideH, insideV — NOT top/bottom/left/right.
    foreach ($side in 'TopBorder', 'LeftBorder', 'BottomBorder', 'RightBorder', 'InsideHorizontalBorder', 'InsideVerticalBorder') {
        $b = New-ScoutDocxEl "$Script:ScoutDocxWNs.$side"
        $b.Val = [DocumentFormat.OpenXml.Wordprocessing.BorderValues]::Single
        $b.Size = [uint32]4
        $b.Color = $Script:ScoutDocxLine
        $borders.Append($b)
    }
    $tblPr.Append($borders)

    $tblLook = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableLook"
    $tblLook.FirstRow = $true
    $tblLook.LastRow = $false
    $tblLook.FirstColumn = $false
    $tblLook.LastColumn = $false
    $tblLook.NoHorizontalBand = $false
    $tblLook.NoVerticalBand = $true
    $tblPr.Append($tblLook)

    $tbl.Append($tblPr)

    $grid = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableGrid"
    foreach ($w in $ColWidthsIn) {
        $gc = New-ScoutDocxEl "$Script:ScoutDocxWNs.GridColumn"
        $gc.Width = "$(ScoutDocxDxa $w)"
        $grid.Append($gc)
    }
    $tbl.Append($grid)

    foreach ($r in $Rows) { $tbl.Append($r) }
    return , $tbl
}

#endregion

#region Data helpers (safe property access, score/severity bands — mirrors Export-Pptx.ps1)

function Get-ScoutDocxProp {
    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-ScoutDocxScoreColor {
    param($Score)
    if ($null -eq $Score) { return $Script:ScoutDocxGray }
    if ($Score -ge 80) { return $Script:ScoutDocxGreen }
    if ($Score -ge 50) { return $Script:ScoutDocxGold }
    return $Script:ScoutDocxRed
}

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

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

function Get-ScoutDocxSeverityLabel {
    param($Severity)
    if ($Severity -and "$Severity".Trim()) { return "$Severity".ToUpperInvariant() }
    return 'UNKNOWN'
}

function Get-ScoutDocxSeverityColor {
    param($Severity)
    if (-not $Severity) { return $Script:ScoutDocxGray }
    switch ($Severity.ToString().Trim().ToLowerInvariant()) {
        'high' { return $Script:ScoutDocxRed }
        'medium' { return $Script:ScoutDocxGold }
        'low' { return $Script:ScoutDocxSteel }
        default { return $Script:ScoutDocxGray }
    }
}

#endregion

#region HTML fallback (non-fatal-on-failure companion, mirrors Export-Pdf.ps1's pattern)

function Export-ScoutDocxHtmlFallback {
    param($Findings, $Collect, [string] $OutputPath, [string] $Reason)
    if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }
    $json = ($Findings | ConvertTo-Json -Depth 100) -replace '</', '<\/'
    $safeReason = $Reason -replace '&', '&amp;' -replace '<', '&lt;' -replace '>', '&gt;'
    $generatedOn = Get-ScoutDocxProp $Findings 'GeneratedOn' '(unknown)'
    $html = @"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Azure Scout Assessment Report (fallback)</title>
<style>
  body { font-family: 'Segoe UI', Arial, sans-serif; color:#1a1a1a; margin: 2rem; }
  .banner { background:#B00020; color:#fff; padding: 0.75rem 1rem; margin-bottom:1rem; }
</style>
</head>
<body>
<div class="banner">
  Word (.docx) generation failed for this run -- this is a plain HTML fallback, not a renamed non-docx file.
  Reason: $safeReason
</div>
<h1>Azure Scout Assessment Report</h1>
<p>Generated: $generatedOn</p>
<script>window.__FINDINGS__ = $json;</script>
</body>
</html>
"@

    $path = Join-Path $OutputPath 'assessment_word_fallback.html'
    $html | Out-File -FilePath $path -Encoding utf8
    return $path
}

#endregion

#region Section builders

function New-ScoutDocxCoverParagraphs {
    param($Body, [string]$Title, [string]$Subtitle, [string]$MetaLine)

    $wmRuns = New-ScoutDocxList
    $wmRuns.Add((New-ScoutDocxRun -Text 'AZURE SCOUT' -SizePt 12 -Hex $Script:ScoutDocxSteel -Bold $true))
    $Body.Append((New-ScoutDocxPara -Runs $wmRuns -Align 'center' -SpaceBeforePt 60 -SpaceAfterPt 12))

    $titleRuns = New-ScoutDocxList
    $titleRuns.Add((New-ScoutDocxRun -Text $Title -SizePt 32 -Hex $Script:ScoutDocxNavy -Bold $true -Font 'Segoe UI Semibold'))
    $Body.Append((New-ScoutDocxPara -Runs $titleRuns -Align 'center' -SpaceAfterPt 8))

    if ($Subtitle) {
        $subRuns = New-ScoutDocxList
        $subRuns.Add((New-ScoutDocxRun -Text $Subtitle -SizePt 15 -Hex $Script:ScoutDocxGray))
        $Body.Append((New-ScoutDocxPara -Runs $subRuns -Align 'center' -SpaceAfterPt 24))
    }

    if ($MetaLine) {
        $metaRuns = New-ScoutDocxList
        $metaRuns.Add((New-ScoutDocxRun -Text $MetaLine -SizePt 11 -Hex $Script:ScoutDocxGray -Italic $true))
        $Body.Append((New-ScoutDocxPara -Runs $metaRuns -Align 'center' -SpaceAfterPt 6))
    }
}

function New-ScoutDocxExecSummary {
    param($Body, $Frameworks, $Areas, $Gaps, $Manual, $Errors)

    Add-ScoutDocxHeading -Body $Body -Text 'Executive Summary' -Level 1

    if (@($Frameworks).Count -eq 0) {
        Add-ScoutDocxParagraph -Body $Body -Text 'No framework scored for this run.' -Hex $Script:ScoutDocxGray
    }
    else {
        $rows = New-ScoutDocxList
        $header = New-ScoutDocxList
        $header.Add((New-ScoutDocxCell -Text 'Framework' -WidthIn 3.0 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
        $header.Add((New-ScoutDocxCell -Text 'Alignment Score' -WidthIn 3.0 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy -Align 'center'))
        $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

        $r = 0
        foreach ($fw in @($Frameworks)) {
            $r++
            $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
            $score = Get-ScoutDocxProp $fw 'Score'
            $scoreText = if ($null -eq $score) { 'Not scored' } else { "$score / 100" }
            $cells = New-ScoutDocxList
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $fw 'Framework')" -WidthIn 3.0 -FillHex $bg))
            $cells.Add((New-ScoutDocxCell -Text $scoreText -WidthIn 3.0 -Bold $true -Hex (Get-ScoutDocxScoreColor $score) -FillHex $bg -Align 'center'))
            $rows.Add((New-ScoutDocxRow -Cells $cells))
        }
        $Body.Append((New-ScoutDocxTable -ColWidthsIn @(3.0, 3.0) -Rows $rows))
    }

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

    Add-ScoutDocxParagraph -Body $Body -Text ' ' -SizePt 4
    foreach ($line in @(
            "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-ScoutDocxParagraph -Body $Body -Text "•   $line" -SizePt 12 } } function New-ScoutDocxAreaFindingsSection { param($Body, $Areas, $AllFindings) Add-ScoutDocxHeading -Body $Body -Text 'Findings by Area' -Level 1 # @(...) wraps the WHOLE pipeline, not just $Areas -- Sort-Object over zero # input emits nothing, which collapses a bare assignment to $null (and # $null.Count throws under Set-StrictMode -Version Latest). Same # load-bearing pattern Get-Score.ps1 documents for its own Pass/Fail counters. $sortedAreas = @(@($Areas) | Sort-Object Framework, Area) if ($sortedAreas.Count -eq 0) { Add-ScoutDocxParagraph -Body $Body -Text 'No areas were assessed for this run.' -Hex $Script:ScoutDocxGray return } foreach ($area in $sortedAreas) { $framework = Get-ScoutDocxProp $area 'Framework' $areaName = Get-ScoutDocxProp $area 'Area' $score = Get-ScoutDocxProp $area 'Score' $scoreText = if ($null -eq $score) { 'not scored' } else { "$score / 100" } Add-ScoutDocxHeading -Body $Body -Text "$framework â€” $areaName (Score: $scoreText)" -Level 2

        $areaFindings = @($AllFindings | Where-Object {
                (Get-ScoutDocxProp $_ 'Framework') -eq $framework -and (Get-ScoutDocxProp $_ 'Area') -eq $areaName
            })

        if ($areaFindings.Count -eq 0) {
            Add-ScoutDocxParagraph -Body $Body -Text 'No individual findings recorded for this area.' -Hex $Script:ScoutDocxGray -Italic $true
            continue
        }

        $rows = New-ScoutDocxList
        $header = New-ScoutDocxList
        foreach ($h in 'Id', 'Severity', 'Status', 'Title') {
            $w = switch ($h) { 'Id' { 1.3 } 'Severity' { 1.0 } 'Status' { 1.0 } default { 3.2 } }
            $header.Add((New-ScoutDocxCell -Text $h -WidthIn $w -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
        }
        $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

        $r = 0
        foreach ($f in ($areaFindings | Sort-Object @{ Expression = { Get-ScoutDocxSeverityRank (Get-ScoutDocxProp $_ 'Severity') } }, Id)) {
            $r++
            $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
            $sevLabel = Get-ScoutDocxSeverityLabel (Get-ScoutDocxProp $f 'Severity')
            $sevColor = Get-ScoutDocxSeverityColor (Get-ScoutDocxProp $f 'Severity')
            $cells = New-ScoutDocxList
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $f 'Id')" -WidthIn 1.3 -FillHex $bg))
            $cells.Add((New-ScoutDocxCell -Text $sevLabel -WidthIn 1.0 -Bold $true -Hex $Script:ScoutDocxPaper -FillHex $sevColor -Align 'center'))
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $f 'Status')" -WidthIn 1.0 -FillHex $bg -Align 'center'))
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $f 'Title')" -WidthIn 3.2 -FillHex $bg))
            $rows.Add((New-ScoutDocxRow -Cells $cells))
        }
        $Body.Append((New-ScoutDocxTable -ColWidthsIn @(1.3, 1.0, 1.0, 3.2) -Rows $rows))
        Add-ScoutDocxParagraph -Body $Body -Text ' ' -SizePt 4
    }
}

function New-ScoutDocxGapsSection {
    param($Body, $Gaps)

    Add-ScoutDocxHeading -Body $Body -Text 'Prioritized Gaps' -Level 1

    # @() wraps the whole pipeline for the same reason New-ScoutDocxAreaFindingsSection
    # wraps its Sort-Object above -- zero-input Sort-Object collapses to $null otherwise.
    $sorted = @(@($Gaps) | Sort-Object @{ Expression = { Get-ScoutDocxSeverityRank (Get-ScoutDocxProp $_ 'Severity') } }, Area)
    $top = @($sorted | Select-Object -First $Script:ScoutDocxMaxGaps)

    if ($top.Count -eq 0) {
        Add-ScoutDocxParagraph -Body $Body -Text 'No prioritized gaps — all assessed rules are passing.' -Hex $Script:ScoutDocxGreen
        return
    }

    $rows = New-ScoutDocxList
    $header = New-ScoutDocxList
    foreach ($h in 'Severity', 'Area', 'Gap') {
        $w = switch ($h) { 'Severity' { 1.1 } 'Area' { 1.7 } default { 3.7 } }
        $header.Add((New-ScoutDocxCell -Text $h -WidthIn $w -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
    }
    $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

    $r = 0
    foreach ($gap in $top) {
        $r++
        $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
        $sevLabel = Get-ScoutDocxSeverityLabel (Get-ScoutDocxProp $gap 'Severity')
        $sevColor = Get-ScoutDocxSeverityColor (Get-ScoutDocxProp $gap 'Severity')
        $cells = New-ScoutDocxList
        $cells.Add((New-ScoutDocxCell -Text $sevLabel -WidthIn 1.1 -Bold $true -Hex $Script:ScoutDocxPaper -FillHex $sevColor -Align 'center'))
        $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $gap 'Area')" -WidthIn 1.7 -FillHex $bg))
        $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $gap 'Title')" -WidthIn 3.7 -FillHex $bg))
        $rows.Add((New-ScoutDocxRow -Cells $cells))
    }
    $Body.Append((New-ScoutDocxTable -ColWidthsIn @(1.1, 1.7, 3.7) -Rows $rows))

    $truncated = @($sorted).Count - $top.Count
    if ($truncated -gt 0) {
        Add-ScoutDocxParagraph -Body $Body -Text "+$truncated more not shown — see the evidence pack (Excel tier) for the full list." -SizePt 9 -Hex $Script:ScoutDocxGray -Italic $true } } function New-ScoutDocxManualSection { param($Body, $Manual) Add-ScoutDocxHeading -Body $Body -Text 'Manual Review Worklist' -Level 1 $items = @($Manual) if ($items.Count -eq 0) { Add-ScoutDocxParagraph -Body $Body -Text 'No manual review items — full automated coverage for the selected assessment(s).' -Hex $Script:ScoutDocxGreen
        return
    }
 
    $shown = @($items | Select-Object -First $Script:ScoutDocxMaxManual)
    $rows = New-ScoutDocxList
    $header = New-ScoutDocxList
    $header.Add((New-ScoutDocxCell -Text '
Area' -WidthIn 2.0 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy)) $header.Add((New-ScoutDocxCell -Text 'Item' -WidthIn 4.5 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy)) $rows.Add((New-ScoutDocxRow -Cells $header -Header $true)) $r = 0 foreach ($m in $shown) { $r++ $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper } $cells = New-ScoutDocxList $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $m 'Area')" -WidthIn 2.0 -FillHex $bg)) $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $m 'Title')" -WidthIn 4.5 -FillHex $bg)) $rows.Add((New-ScoutDocxRow -Cells $cells)) } $Body.Append((New-ScoutDocxTable -ColWidthsIn @(2.0, 4.5) -Rows $rows)) $truncated = $items.Count - $shown.Count if ($truncated -gt 0) { Add-ScoutDocxParagraph -Body $Body -Text "+$truncated more not shown — see the evidence pack (Excel tier) for the full list." -SizePt 9 -Hex $Script:ScoutDocxGray -Italic $true } } function New-ScoutDocxSectionProperties { # US Letter, portrait, 1" top/bottom, 0.75" left/right — twips (dxa) throughout. $sectPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.SectionProperties" $pgSz = New-ScoutDocxEl "$Script:ScoutDocxWNs.PageSize" $pgSz.Width = [uint32](ScoutDocxDxa 8.5) $pgSz.Height = [uint32](ScoutDocxDxa 11) $sectPr.Append($pgSz) $pgMar = New-ScoutDocxEl "$Script:ScoutDocxWNs.PageMargin" $pgMar.Top = [int32](ScoutDocxDxa 1.0) $pgMar.Bottom = [int32](ScoutDocxDxa 1.0) $pgMar.Left = [uint32](ScoutDocxDxa 0.75) $pgMar.Right = [uint32](ScoutDocxDxa 0.75) $pgMar.Header = [uint32](ScoutDocxDxa 0.5) $pgMar.Footer = [uint32](ScoutDocxDxa 0.5) $pgMar.Gutter = [uint32]0 $sectPr.Append($pgMar) return , $sectPr } #endregion function Export-Word { <# .SYNOPSIS Renders the Word (.docx) assessment report 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/-Pptx already receive from Export-Report.ps1). Used only to surface scope / management-group context on the cover page when present. .PARAMETER OutputPath Directory the rendered assessment_report.docx is written into. #> param($Findings, $Collect, [string] $OutputPath) try { Import-ScoutDocxOpenXmlAssembly if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $outFile = Join-Path $OutputPath 'assessment_report.docx' if (Test-Path $outFile) { Remove-Item $outFile -Force } $frameworks = @(Get-ScoutDocxProp $Findings 'Frameworks') $areas = @(Get-ScoutDocxProp $Findings 'Areas') $gaps = @(Get-ScoutDocxProp $Findings 'Gaps') $manual = @(Get-ScoutDocxProp $Findings 'Manual') $errors = @(Get-ScoutDocxProp $Findings 'Errors') $allFindings = @(Get-ScoutDocxProp $Findings 'Findings') $generatedOn = Get-ScoutDocxProp $Findings 'GeneratedOn' $generatedText = if ($generatedOn) { try { ([datetime]$generatedOn).ToString('yyyy-MM-dd') } catch { "$generatedOn" } } else { (Get-Date).ToString('yyyy-MM-dd') } $scope = Get-ScoutDocxProp (Get-ScoutDocxProp $Collect '_meta') 'scope' $mgId = Get-ScoutDocxProp (Get-ScoutDocxProp $Collect '_meta') 'managementGroupId' $metaParts = [System.Collections.Generic.List[string]]::new() $metaParts.Add("Generated $generatedText") if ($scope) { $metaParts.Add("Scope: $scope") } if ($mgId) { $metaParts.Add("Management Group: $mgId") } $metaLine = [string]::Join('  Â·  ', $metaParts)
 
        $doc = [DocumentFormat.OpenXml.Packaging.WordprocessingDocument]::Create($outFile, [DocumentFormat.OpenXml.WordprocessingDocumentType]::Document)
        $mainPart = $doc.AddMainDocumentPart()
        $mainPart.Document = New-ScoutDocxEl "$Script:ScoutDocxWNs.Document"
        $body = New-ScoutDocxEl "$Script:ScoutDocxWNs.Body"
        $mainPart.Document.Append($body)
 
        # ---- Cover ----
        New-ScoutDocxCoverParagraphs -Body $body -Title '
Azure Landing Zone Assessment' ` -Subtitle 'Executive Assessment â€” CAF & WAF Alignment' -MetaLine $metaLine Add-ScoutDocxPageBreak -Body $body # ---- Executive Summary ---- New-ScoutDocxExecSummary -Body $body -Frameworks $frameworks -Areas $areas -Gaps $gaps -Manual $manual -Errors $errors Add-ScoutDocxPageBreak -Body $body # ---- Findings by Area ---- New-ScoutDocxAreaFindingsSection -Body $body -Areas $areas -AllFindings $allFindings Add-ScoutDocxPageBreak -Body $body # ---- Prioritized Gaps ---- New-ScoutDocxGapsSection -Body $body -Gaps $gaps Add-ScoutDocxPageBreak -Body $body # ---- Manual Review Worklist ---- New-ScoutDocxManualSection -Body $body -Manual $manual # ---- Section properties (must be the last child of w:body) ---- $body.Append((New-ScoutDocxSectionProperties)) $mainPart.Document.Save() $doc.Dispose() return $outFile } catch { Write-Warning "Export-Word: .docx generation failed ($_) -- writing an HTML fallback instead." return (Export-ScoutDocxHtmlFallback -Findings $Findings -Collect $Collect -OutputPath $OutputPath -Reason $_.Exception.Message) } }