src/report/Build-ScoutFigure.ps1

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

<#
.SYNOPSIS
    Render report figures to PNG with no external dependency.
 
.DESCRIPTION
    AB#6737 / AB#6885, clauses D-01, D-02, D-03 and W-12.
 
    Phase 0's finding was that the diagram pipeline emits `.drawio` XML and nothing else, so no
    document could embed a figure even when one had been generated. Every attempt to fix that so
    far has proposed a dependency — Graphviz for AzViz, a headless browser for draw.io export,
    ImageMagick — and every one of them fails the same test: Scout is a PowerShell module people
    install from the gallery and run on a laptop, and a report that silently loses its figures
    because a native binary is missing is worse than one that never promised them.
 
    So this rasterises in managed code. A PNG is a zlib stream of filtered scanlines wrapped in
    four chunks; zlib's STORED block type needs no compressor, only the two checksums. That is
    the whole trick, and it is why this file has no `Add-Type`, no P/Invoke and no `dotnet`
    acquire — unlike Export-Word.ps1, which genuinely needs the OpenXML SDK.
 
    System.Drawing is deliberately not used: `System.Drawing.Common` is Windows-only from .NET 6
    onwards, and this module is expected to run wherever PowerShell 7 does.
 
    What it draws is the set of figures the assessment report actually argues with — alignment
    by area, control status composition, and a severity-by-area heatmap. It is not a general
    graphics library and should not grow into one.
 
.NOTES
    Text is drawn from a 5x7 bitmap font. That is a deliberate ceiling: a figure label is a word
    or a number, and a renderer that needed real typography would need a font file, which is a
    dependency again.
#>


#region PNG encoding

# Declared at script scope rather than created on first use inside the function: under
# Set-StrictMode -Version Latest, reading an unset variable THROWS rather than returning $null,
# so the usual `if (-not $Script:Table)` lazy-init pattern fails on its own guard. This is the
# same StrictMode class documented across this repo.
$Script:ScoutCrcTable = $null

function Get-ScoutPngCrc32 {
    param([Parameter(Mandatory)][byte[]]$Bytes)

    # PowerShell parses the literal 0xFFFFFFFF as Int32 -1, not as a uint32, so every constant
    # here goes through [uint32]::MaxValue or an explicit cast. Getting that wrong produces a
    # "cannot convert -1 to UInt32" a long way from its cause.
    $poly = [uint32]3988292384   # 0xEDB88320, reversed CRC-32 polynomial
    $allOnes = [uint32]::MaxValue

    if (-not $Script:ScoutCrcTable) {
        $Script:ScoutCrcTable = [uint32[]]::new(256)
        for ($n = 0; $n -lt 256; $n++) {
            [uint32]$c = [uint32]$n
            for ($k = 0; $k -lt 8; $k++) {
                if (($c -band [uint32]1) -ne 0) { $c = [uint32]($poly -bxor ($c -shr 1)) }
                else { $c = [uint32]($c -shr 1) }
            }
            $Script:ScoutCrcTable[$n] = $c
        }
    }

    [uint32]$crc = $allOnes
    foreach ($b in $Bytes) {
        $idx = [int](($crc -bxor [uint32]$b) -band [uint32]0xFF)
        $crc = [uint32]($Script:ScoutCrcTable[$idx] -bxor ($crc -shr 8))
    }
    return [uint32]($crc -bxor $allOnes)
}

function Get-ScoutPngAdler32 {
    param([Parameter(Mandatory)][byte[]]$Bytes)
    [uint32]$a = 1
    [uint32]$b = 0
    foreach ($byte in $Bytes) {
        $a = [uint32](($a + $byte) % 65521)
        $b = [uint32](($b + $a) % 65521)
    }
    return [uint32](($b -shl 16) -bor $a)
}

function ConvertTo-ScoutBigEndian {
    param([Parameter(Mandatory)][uint32]$Value)
    return [byte[]]@(
        [byte](($Value -shr 24) -band 0xFF)
        [byte](($Value -shr 16) -band 0xFF)
        [byte](($Value -shr 8) -band 0xFF)
        [byte]($Value -band 0xFF)
    )
}

function New-ScoutPngChunk {
    param([Parameter(Mandatory)][string]$Type, [byte[]]$Data = @())
    $typeBytes = [System.Text.Encoding]::ASCII.GetBytes($Type)
    $payload = [byte[]]($typeBytes + $Data)
    return [byte[]](
        (ConvertTo-ScoutBigEndian ([uint32]$Data.Length)) +
        $payload +
        (ConvertTo-ScoutBigEndian (Get-ScoutPngCrc32 -Bytes $payload))
    )
}

function ConvertTo-ScoutZlibStream {
    <#
    .SYNOPSIS
        Wrap raw bytes in a zlib stream.
 
    .DESCRIPTION
        `System.IO.Compression.DeflateStream` produces a RAW deflate stream and ships in the base
        class library, so the compression itself costs no dependency. zlib is that raw stream
        plus a two-byte header and a trailing Adler-32, both of which are trivial to add — which
        is the whole reason a PNG can be written from managed code with nothing installed.
 
        The header bytes are 0x78 0x01: deflate, 32K window, no preset dictionary, and check
        bits chosen so (CMF * 256 + FLG) is divisible by 31.
    #>

    param([Parameter(Mandatory)][byte[]]$Bytes)

    $ms = [System.IO.MemoryStream]::new()
    try {
        $deflate = [System.IO.Compression.DeflateStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal, $true)
        try { $deflate.Write($Bytes, 0, $Bytes.Length) } finally { $deflate.Dispose() }
        $compressed = $ms.ToArray()
    }
    finally { $ms.Dispose() }

    return [byte[]](
        [byte[]]@(0x78, 0x01) +
        $compressed +
        (ConvertTo-ScoutBigEndian (Get-ScoutPngAdler32 -Bytes $Bytes))
    )
}

function ConvertTo-ScoutPngBytes {
    <#
    .SYNOPSIS
        Encode an RGB pixel buffer as a PNG.
 
    .PARAMETER Pixels
        Row-major RGB triples: 3 bytes per pixel, Width * Height * 3 in total.
    #>

    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)][int]$Width,
        [Parameter(Mandatory)][int]$Height,
        [Parameter(Mandatory)][byte[]]$Pixels
    )

    $expected = $Width * $Height * 3
    if ($Pixels.Length -ne $expected) {
        throw "ConvertTo-ScoutPngBytes: expected $expected pixel bytes for ${Width}x${Height}, got $($Pixels.Length)."
    }

    # Each scanline is prefixed with its filter type. 0 (None) keeps the encoder honest and
    # costs only one byte a row; a real filter would need a compressor to pay for itself.
    $raw = [byte[]]::new($Height * (1 + $Width * 3))
    $stride = $Width * 3
    for ($y = 0; $y -lt $Height; $y++) {
        $dst = $y * (1 + $stride)
        $raw[$dst] = 0
        [Array]::Copy($Pixels, $y * $stride, $raw, $dst + 1, $stride)
    }

    $ihdr = [byte[]](
        (ConvertTo-ScoutBigEndian ([uint32]$Width)) +
        (ConvertTo-ScoutBigEndian ([uint32]$Height)) +
        [byte[]]@(8, 2, 0, 0, 0)   # 8-bit, truecolour RGB, deflate, adaptive filter, no interlace
    )

    $signature = [byte[]]@(137, 80, 78, 71, 13, 10, 26, 10)
    return [byte[]](
        $signature +
        (New-ScoutPngChunk -Type 'IHDR' -Data $ihdr) +
        (New-ScoutPngChunk -Type 'IDAT' -Data (ConvertTo-ScoutZlibStream -Bytes $raw)) +
        (New-ScoutPngChunk -Type 'IEND')
    )
}

#endregion

#region Canvas

function New-ScoutCanvas {
    param(
        [Parameter(Mandatory)][int]$Width,
        [Parameter(Mandatory)][int]$Height,
        [string]$BackgroundHex = 'FFFFFF'
    )
    $rgb = ConvertFrom-ScoutHex -Hex $BackgroundHex
    $pixels = [byte[]]::new($Width * $Height * 3)
    # White is the overwhelmingly common background and a fresh byte[] is already zeroed, so a
    # non-white fill is the only case that needs the loop at all. On a 900x520 canvas that is
    # 1.4 million writes avoided per figure, which is the difference between a figure set that
    # renders in a second and one that renders in a minute.
    if ($rgb[0] -ne 0 -or $rgb[1] -ne 0 -or $rgb[2] -ne 0) {
        [Array]::Fill($pixels, [byte]0)
        for ($i = 0; $i -lt $pixels.Length; $i += 3) {
            $pixels[$i] = $rgb[0]; $pixels[$i + 1] = $rgb[1]; $pixels[$i + 2] = $rgb[2]
        }
    }
    return [pscustomobject]@{ Width = $Width; Height = $Height; Pixels = $pixels }
}

function ConvertFrom-ScoutHex {
    [OutputType([byte[]])]
    param([Parameter(Mandatory)][string]$Hex)
    $h = $Hex.TrimStart('#')
    return [byte[]]@(
        [Convert]::ToByte($h.Substring(0, 2), 16)
        [Convert]::ToByte($h.Substring(2, 2), 16)
        [Convert]::ToByte($h.Substring(4, 2), 16)
    )
}

function Set-ScoutCanvasPixel {
    param($Canvas, [int]$X, [int]$Y, [byte[]]$Rgb)
    if ($X -lt 0 -or $Y -lt 0 -or $X -ge $Canvas.Width -or $Y -ge $Canvas.Height) { return }
    $i = ($Y * $Canvas.Width + $X) * 3
    $Canvas.Pixels[$i] = $Rgb[0]; $Canvas.Pixels[$i + 1] = $Rgb[1]; $Canvas.Pixels[$i + 2] = $Rgb[2]
}

function Set-ScoutCanvasRect {
    <#
    .SYNOPSIS
        Fill an axis-aligned rectangle.
 
    .DESCRIPTION
        The pixel writes are INLINE rather than routed through Set-ScoutCanvasPixel on purpose.
        A PowerShell function call costs microseconds, and a heatmap fills well over a hundred
        thousand pixels — going through the helper turns a one-second figure into a minute-long
        one. Clipping is done once against the rectangle instead of once per pixel for the same
        reason.
    #>

    param($Canvas, [int]$X, [int]$Y, [int]$Width, [int]$Height, [Parameter(Mandatory)][string]$Hex)
    if ($Width -le 0 -or $Height -le 0) { return }
    $rgb = ConvertFrom-ScoutHex -Hex $Hex
    $r = $rgb[0]; $g = $rgb[1]; $b = $rgb[2]

    $x0 = [Math]::Max(0, $X)
    $y0 = [Math]::Max(0, $Y)
    $x1 = [Math]::Min($Canvas.Width, $X + $Width)
    $y1 = [Math]::Min($Canvas.Height, $Y + $Height)
    if ($x1 -le $x0 -or $y1 -le $y0) { return }

    $pixels = $Canvas.Pixels
    $stride = $Canvas.Width * 3
    for ($yy = $y0; $yy -lt $y1; $yy++) {
        $i = $yy * $stride + $x0 * 3
        for ($xx = $x0; $xx -lt $x1; $xx++) {
            $pixels[$i] = $r; $pixels[$i + 1] = $g; $pixels[$i + 2] = $b
            $i += 3
        }
    }
}

#endregion

#region Bitmap text

# A 5x7 font, one hex byte per column, bit 0 = top row. Only the glyphs a figure label actually
# uses are defined; anything else falls back to a blank cell, which keeps a stray character from
# turning into a wrong one. Deliberately small: see this file's header on why there is no font
# file to load.
$Script:ScoutGlyphs = @{
    'A' = '7E', '11', '11', '11', '7E'
    'B' = '7F', '49', '49', '49', '36'
    'C' = '3E', '41', '41', '41', '22'
    'D' = '7F', '41', '41', '41', '3E'
    'E' = '7F', '49', '49', '49', '41'
    'F' = '7F', '09', '09', '09', '01'
    'G' = '3E', '41', '49', '49', '7A'
    'H' = '7F', '08', '08', '08', '7F'
    'I' = '00', '41', '7F', '41', '00'
    'J' = '20', '40', '41', '3F', '01'
    'K' = '7F', '08', '14', '22', '41'
    'L' = '7F', '40', '40', '40', '40'
    'M' = '7F', '02', '0C', '02', '7F'
    'N' = '7F', '04', '08', '10', '7F'
    'O' = '3E', '41', '41', '41', '3E'
    'P' = '7F', '09', '09', '09', '06'
    'Q' = '3E', '41', '51', '21', '5E'
    'R' = '7F', '09', '19', '29', '46'
    'S' = '46', '49', '49', '49', '31'
    'T' = '01', '01', '7F', '01', '01'
    'U' = '3F', '40', '40', '40', '3F'
    'V' = '1F', '20', '40', '20', '1F'
    'W' = '7F', '20', '18', '20', '7F'
    'X' = '63', '14', '08', '14', '63'
    'Y' = '03', '04', '78', '04', '03'
    'Z' = '61', '51', '49', '45', '43'
    '0' = '3E', '51', '49', '45', '3E'
    '1' = '00', '42', '7F', '40', '00'
    '2' = '42', '61', '51', '49', '46'
    '3' = '21', '41', '45', '4B', '31'
    '4' = '18', '14', '12', '7F', '10'
    '5' = '27', '45', '45', '45', '39'
    '6' = '3C', '4A', '49', '49', '30'
    '7' = '01', '71', '09', '05', '03'
    '8' = '36', '49', '49', '49', '36'
    '9' = '06', '49', '49', '29', '1E'
    '.' = '00', '60', '60', '00', '00'
    ',' = '00', '50', '30', '00', '00'
    ':' = '00', '36', '36', '00', '00'
    '-' = '08', '08', '08', '08', '08'
    '/' = '20', '10', '08', '04', '02'
    '%' = '23', '13', '08', '64', '62'
    '(' = '00', '1C', '22', '41', '00'
    ')' = '00', '41', '22', '1C', '00'
    ' ' = '00', '00', '00', '00', '00'
}

function Set-ScoutCanvasText {
    <#
    .SYNOPSIS
        Draw a string at (X, Y), scaled by whole pixels.
    #>

    param(
        $Canvas,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [int]$X,
        [int]$Y,
        [string]$Hex = '1A1A1A',
        [int]$Scale = 2
    )
    if ([string]::IsNullOrEmpty($Text)) { return }
    # Validate the colour once here rather than letting a bad hex throw from inside the glyph
    # loop, where the error would name a rectangle rather than the label that caused it.
    $null = ConvertFrom-ScoutHex -Hex $Hex
    $cursor = $X
    foreach ($ch in $Text.ToUpperInvariant().ToCharArray()) {
        $key = [string]$ch
        $cols = if ($Script:ScoutGlyphs.ContainsKey($key)) { $Script:ScoutGlyphs[$key] } else { $Script:ScoutGlyphs[' '] }
        for ($c = 0; $c -lt 5; $c++) {
            $bits = [Convert]::ToInt32($cols[$c], 16)
            for ($r = 0; $r -lt 7; $r++) {
                if (($bits -shr $r) -band 1) {
                    # One filled rect per lit font pixel: at Scale 2 that is a 2x2 block, and the
                    # rect helper already clips, so a label running off the canvas edge truncates
                    # instead of throwing.
                    Set-ScoutCanvasRect -Canvas $Canvas -X ($cursor + $c * $Scale) -Y ($Y + $r * $Scale) `
                        -Width $Scale -Height $Scale -Hex $Hex
                }
            }
        }
        $cursor += 6 * $Scale
    }
}

function Get-ScoutTextWidth {
    [OutputType([int])]
    param([AllowEmptyString()][string]$Text, [int]$Scale = 2)
    if ([string]::IsNullOrEmpty($Text)) { return 0 }
    return ($Text.Length * 6 * $Scale)
}

#endregion

#region Figures

# The chart palette measured under AB#6874. These are SERIES colours, deliberately not the
# document chrome — navy and steel failed the normal-vision distinguishability floor against
# each other and were the two most-used series colours in every chart Scout drew.
$Script:ScoutFigureStatusHex = @{
    Pass    = '0CA30C'
    Partial = 'FAB219'
    Fail    = 'D03B3B'
    Error   = 'EC835A'
    Manual  = '2A78D6'
    Unknown = '6E7079'
}
$Script:ScoutFigureInk = '1A1A1A'
$Script:ScoutFigureMuted = '595959'
$Script:ScoutFigureGrid = 'D9D9D9'
$Script:ScoutFigureSurface = 'FFFFFF'

function Get-ScoutFigureProp {
    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 New-ScoutFigureAreaScores {
    <#
    .SYNOPSIS
        Horizontal bars — alignment score per assessed area, worst first.
 
    .DESCRIPTION
        Worst-first rather than alphabetical because the reader's question is "where is the
        problem", and a chart sorted by name makes them find it themselves. Every bar carries a
        direct value label in text ink rather than in the series colour — the relief rule from
        the AB#6874 palette validation, which is what keeps a sub-3:1 fill legible.
    #>

    param($Areas, [int]$Width = 900, [int]$RowHeight = 34)

    $rows = @(@($Areas) | Where-Object { $null -ne (Get-ScoutFigureProp $_ 'Score') } |
            Sort-Object @{ Expression = { [double](Get-ScoutFigureProp $_ 'Score' 0) } } |
            Select-Object -First 14)
    if ($rows.Count -eq 0) { return $null }

    $top = 44
    $height = $top + ($rows.Count * $RowHeight) + 20
    $canvas = New-ScoutCanvas -Width $Width -Height $height -BackgroundHex $Script:ScoutFigureSurface
    Set-ScoutCanvasText -Canvas $canvas -Text 'Alignment score by area' -X 16 -Y 14 -Hex $Script:ScoutFigureInk -Scale 2

    $labelWidth = 300
    $barLeft = $labelWidth + 16
    $barMax = $Width - $barLeft - 90

    for ($i = 0; $i -lt $rows.Count; $i++) {
        $r = $rows[$i]
        $y = $top + ($i * $RowHeight)
        $score = [double](Get-ScoutFigureProp $r 'Score' 0)
        $label = "$(Get-ScoutFigureProp $r 'Area')"
        if ($label.Length -gt 24) { $label = $label.Substring(0, 24) }

        Set-ScoutCanvasText -Canvas $canvas -Text $label -X 16 -Y ($y + 6) -Hex $Script:ScoutFigureInk -Scale 2
        Set-ScoutCanvasRect -Canvas $canvas -X $barLeft -Y ($y + 4) -Width $barMax -Height 18 -Hex $Script:ScoutFigureGrid

        $fillHex = if ($score -ge 80) { $Script:ScoutFigureStatusHex.Pass }
        elseif ($score -ge 50) { $Script:ScoutFigureStatusHex.Partial }
        else { $Script:ScoutFigureStatusHex.Fail }
        $barLen = [int][Math]::Round($barMax * ([Math]::Max(0, [Math]::Min(100, $score)) / 100))
        Set-ScoutCanvasRect -Canvas $canvas -X $barLeft -Y ($y + 4) -Width $barLen -Height 18 -Hex $fillHex

        Set-ScoutCanvasText -Canvas $canvas -Text ("{0:N0}" -f $score) -X ($barLeft + $barMax + 12) -Y ($y + 6) -Hex $Script:ScoutFigureInk -Scale 2
    }

    return [pscustomobject]@{
        Name    = 'area-scores'
        Caption = 'Figure 1 — Alignment score by assessed area, worst first.'
        Width   = $Width
        Height  = $height
        Bytes   = (ConvertTo-ScoutPngBytes -Width $Width -Height $height -Pixels $canvas.Pixels)
        RawRgb  = $canvas.Pixels
    }
}

function New-ScoutFigureStatusComposition {
    <#
    .SYNOPSIS
        A single stacked bar — how the run's controls divide across the status states.
 
    .DESCRIPTION
        A stacked bar rather than a pie: the reader's question is proportion against a whole they
        already know the size of, and a pie makes small slices unreadable exactly where the
        interesting ones are. Not assessed gets its own segment (clause W-17) rather than being
        folded into failures.
    #>

    param($AllFindings, [int]$Width = 900, [int]$Height = 190)

    $findings = @($AllFindings)
    if ($findings.Count -eq 0) { return $null }

    $order = @('Pass', 'Partial', 'Fail', 'Error', 'Manual', 'Unknown')
    $counts = [ordered]@{}
    foreach ($k in $order) { $counts[$k] = 0 }
    foreach ($f in $findings) {
        $s = "$(Get-ScoutFigureProp $f 'Status')"
        if ($counts.Contains($s)) { $counts[$s]++ } else { $counts['Unknown']++ }
    }

    $canvas = New-ScoutCanvas -Width $Width -Height $Height -BackgroundHex $Script:ScoutFigureSurface
    Set-ScoutCanvasText -Canvas $canvas -Text 'Controls by status' -X 16 -Y 14 -Hex $Script:ScoutFigureInk -Scale 2

    $barLeft = 16
    $barWidth = $Width - 32
    $barTop = 50
    $barHeight = 40
    $total = $findings.Count
    $x = $barLeft
    foreach ($k in $order) {
        $n = $counts[$k]
        if ($n -le 0) { continue }
        $w = [int][Math]::Round($barWidth * ($n / $total))
        Set-ScoutCanvasRect -Canvas $canvas -X $x -Y $barTop -Width $w -Height $barHeight -Hex $Script:ScoutFigureStatusHex[$k]
        # A 2px surface-coloured ring between segments, so touching fills read as separate marks.
        Set-ScoutCanvasRect -Canvas $canvas -X ($x + $w - 2) -Y $barTop -Width 2 -Height $barHeight -Hex $Script:ScoutFigureSurface
        $x += $w
    }

    # Legend, with the count on every entry — a segment the reader cannot measure is decoration.
    $lx = $barLeft
    $ly = $barTop + $barHeight + 22
    foreach ($k in $order) {
        $n = $counts[$k]
        if ($n -le 0) { continue }
        $label = if ($k -eq 'Manual') { "NOT ASSESSED $n" } else { "$($k.ToUpperInvariant()) $n" }
        Set-ScoutCanvasRect -Canvas $canvas -X $lx -Y $ly -Width 14 -Height 14 -Hex $Script:ScoutFigureStatusHex[$k]
        Set-ScoutCanvasText -Canvas $canvas -Text $label -X ($lx + 20) -Y ($ly + 1) -Hex $Script:ScoutFigureMuted -Scale 2
        $lx += 20 + (Get-ScoutTextWidth -Text $label -Scale 2) + 24
    }

    return [pscustomobject]@{
        Name    = 'status-composition'
        Caption = 'Figure 2 — Every control evaluated in this run, by status. "Not assessed" is a state, not a failure.'
        Width   = $Width
        Height  = $Height
        Bytes   = (ConvertTo-ScoutPngBytes -Width $Width -Height $Height -Pixels $canvas.Pixels)
        RawRgb  = $canvas.Pixels
    }
}

function New-ScoutFigureSeverityHeatmap {
    <#
    .SYNOPSIS
        Area x severity heatmap of failing controls — where the risk is concentrated.
    #>

    param($AllFindings, [int]$Width = 900)

    $fails = @(@($AllFindings) | Where-Object { "$(Get-ScoutFigureProp $_ 'Status')" -eq 'Fail' })
    if ($fails.Count -eq 0) { return $null }

    $severities = @('high', 'medium', 'low')
    $areas = @($fails | ForEach-Object { "$(Get-ScoutFigureProp $_ 'Area')" } | Sort-Object -Unique | Select-Object -First 12)

    $cellW = 120
    $cellH = 36
    $left = 300
    $top = 76
    $height = $top + ($areas.Count * $cellH) + 20
    $canvas = New-ScoutCanvas -Width $Width -Height $height -BackgroundHex $Script:ScoutFigureSurface
    Set-ScoutCanvasText -Canvas $canvas -Text 'Failing controls by area and severity' -X 16 -Y 14 -Hex $Script:ScoutFigureInk -Scale 2

    for ($c = 0; $c -lt $severities.Count; $c++) {
        Set-ScoutCanvasText -Canvas $canvas -Text $severities[$c] -X ($left + $c * $cellW + 8) -Y ($top - 24) -Hex $Script:ScoutFigureMuted -Scale 2
    }

    # The scale is normalised to the busiest cell in THIS run, so the darkest cell always means
    # "the worst one here" rather than "past some absolute threshold nobody agreed".
    $matrix = @{}
    $peak = 1
    foreach ($a in $areas) {
        foreach ($s in $severities) {
            $n = @($fails | Where-Object {
                    "$(Get-ScoutFigureProp $_ 'Area')" -eq $a -and
                    "$(Get-ScoutFigureProp $_ 'Severity')".ToLowerInvariant() -eq $s
                }).Count
            $matrix["$a|$s"] = $n
            if ($n -gt $peak) { $peak = $n }
        }
    }

    for ($r = 0; $r -lt $areas.Count; $r++) {
        $a = $areas[$r]
        $y = $top + ($r * $cellH)
        $label = if ($a.Length -gt 24) { $a.Substring(0, 24) } else { $a }
        Set-ScoutCanvasText -Canvas $canvas -Text $label -X 16 -Y ($y + 10) -Hex $Script:ScoutFigureInk -Scale 2
        for ($c = 0; $c -lt $severities.Count; $c++) {
            $n = $matrix["$a|$($severities[$c])"]
            $x = $left + $c * $cellW
            $hex = if ($n -eq 0) { $Script:ScoutFigureGrid } else {
                $t = $n / $peak
                $base = ConvertFrom-ScoutHex -Hex $Script:ScoutFigureStatusHex.Fail
                # Blend towards the surface for lighter cells, so intensity reads as intensity.
                '{0:X2}{1:X2}{2:X2}' -f `
                ([int](255 - (255 - $base[0]) * $t)),
                ([int](255 - (255 - $base[1]) * $t)),
                ([int](255 - (255 - $base[2]) * $t))
            }
            Set-ScoutCanvasRect -Canvas $canvas -X $x -Y $y -Width ($cellW - 6) -Height ($cellH - 6) -Hex $hex
            # Direct value label on every cell — this is the relief rule again: the fill alone
            # cannot be read to a number, so the number is written on it.
            Set-ScoutCanvasText -Canvas $canvas -Text "$n" -X ($x + 8) -Y ($y + 8) -Hex $Script:ScoutFigureInk -Scale 2
        }
    }

    return [pscustomobject]@{
        Name    = 'severity-heatmap'
        Caption = 'Figure 3 — Failing controls by area and severity. Shading is relative to the busiest cell in this run.'
        Width   = $Width
        Height  = $height
        Bytes   = (ConvertTo-ScoutPngBytes -Width $Width -Height $height -Pixels $canvas.Pixels)
        RawRgb  = $canvas.Pixels
    }
}

function Export-ScoutFigureSet {
    <#
    .SYNOPSIS
        Render every report figure to PNG and return descriptors for the renderers to embed.
 
    .DESCRIPTION
        Clause D-02: a figure that fails to render is reported as a warning and OMITTED, never
        emitted broken or empty. Each figure is built inside its own try/catch for exactly that
        reason — one bad data shape must not cost the document the other two figures, and a
        zero-byte PNG in a Word package is a corrupt document rather than a missing picture.
 
        Clause D-01: each figure is written to disk as a PNG alongside whatever source form the
        diagram pipeline produced, so the rasterised output is a first-class artefact and not
        only an embedded blob.
 
    .OUTPUTS
        Descriptors with Name, Caption, Width, Height, Bytes and Path.
    #>

    param($Findings, [Parameter(Mandatory)][string]$OutputPath)

    $figDir = Join-Path $OutputPath 'figures'
    if (-not (Test-Path $figDir)) { New-Item -ItemType Directory -Path $figDir -Force | Out-Null }

    $areas = @(Get-ScoutFigureProp $Findings 'Areas')
    $all = @(Get-ScoutFigureProp $Findings 'Findings')

    $builders = @(
        @{ Name = 'area-scores'; Script = { New-ScoutFigureAreaScores -Areas $areas } }
        @{ Name = 'status-composition'; Script = { New-ScoutFigureStatusComposition -AllFindings $all } }
        @{ Name = 'severity-heatmap'; Script = { New-ScoutFigureSeverityHeatmap -AllFindings $all } }
    )

    $result = [System.Collections.Generic.List[object]]::new()
    foreach ($b in $builders) {
        try {
            $fig = & $b.Script
            if ($null -eq $fig) { continue }
            $path = Join-Path $figDir "$($fig.Name).png"
            [System.IO.File]::WriteAllBytes($path, $fig.Bytes)
            $result.Add(([pscustomobject]@{
                        Name    = $fig.Name
                        Caption = $fig.Caption
                        Width   = $fig.Width
                        Height  = $fig.Height
                        Bytes   = $fig.Bytes
                        # The raw RGB buffer travels with the descriptor because the PDF renderer
                        # cannot read a PNG -- it embeds an image XObject with /FlateDecode, which
                        # IS zlib, so it flate-encodes these pixels directly rather than decoding
                        # and re-encoding. Word and PowerPoint take .Bytes (the PNG) instead.
                        RawRgb  = $fig.RawRgb
                        Path    = $path
                    }))
        }
        catch {
            Write-Warning "Export-ScoutFigureSet: figure '$($b.Name)' did not render ($($_.Exception.Message)) — omitted from this run's report."
        }
    }

    # Returned WITHOUT the usual unary comma. These are plain descriptors, not OpenXmlElements,
    # so there is nothing to protect from pipeline unrolling -- and returning the array as a
    # single object here made the caller's `@(...)` produce an array-of-one-array, which then
    # tried to embed all three figures as if they were one.
    return $result.ToArray()
}

#endregion