diff --git a/WARP/devops/GenerateAssessmentReport.ps1 b/WARP/devops/GenerateAssessmentReport.ps1
index 081c71e..c90147a 100644
--- a/WARP/devops/GenerateAssessmentReport.ps1
+++ b/WARP/devops/GenerateAssessmentReport.ps1
@@ -25,12 +25,12 @@
.\GenerateAssessmentReport.ps1 -ContentFile .\mycontent.csv -GenAI
- Ensure the powerpoint template file and the Category Descriptions file exist in the paths shown below before attempting to run this script
- Once the script is run, close the powershell window and a timestamped PowerPoint report and a subset csv file will be created on the working directory
+ Ensure the PowerPoint template file and the Category Descriptions file exist in the paths shown below before attempting to run this script
+ Once the script is run, close the PowerShell window and a timestamped PowerPoint report and a subset CSV file will be created in the working directory
Use these reports to represent and edit your findings for the WAF Engagement
Known issues
- - If the hyperlinks are not being published accurately, ensure that the csv file doesnt have any multi-sentence recommendations under Link-Text field
+ - If the hyperlinks are not being published accurately, ensure that the CSV file doesn't have any multi-sentence recommendations under the Link-Text field
.PARAMETER ContentFile
@@ -51,6 +51,9 @@
.PARAMETER ShowTop
How many recommendations to try to fit on a slide. 8 is default.
+.PARAMETER OutputDirectory
+ Directory where the filtered CSV and generated PPTX report should be written. Defaults to the current working directory.
+
.INPUTS
ContentFile should be a CSV-formatted Well-Architected Assessment export
@@ -59,26 +62,26 @@
CSV artifact suitable for use with the DevOps/GitHub import scripts
.EXAMPLE
- .\generateAssessmentReport.ps1
+ .\GenerateAssessmentReport.ps1
If no -ContentFile is specified, a file browser dialog box will be shown and the file may be selected.
Generates a PPTX report from a Well-Architected Review site exported CSV.
.EXAMPLE
- .\generateAssessmentReport.ps1 -ContentFile .\Cloud_Adoption_Security_Assessment_Sample.csv -ShowTop 9
+ .\GenerateAssessmentReport.ps1 -ContentFile .\Cloud_Adoption_Security_Assessment_Sample.csv -ShowTop 9
Generates a PPTX report from a CASA CSV
Tries to include the top 9 results of any category (which probably won't fit by default, so plan to reformat things)
.EXAMPLE
- .\generateAssessmentReport.ps1 -ContentFile .\Cloud_Adoption_Security_Assessment_Sample.csv -CloudAdoption
+ .\GenerateAssessmentReport.ps1 -ContentFile .\Cloud_Adoption_Security_Assessment_Sample.csv -CloudAdoption
If the title doesn't identify the report type correctly, you can force the decision with the relevant switch. (for example: -CloudAdoption, -DevOpsCapability)
.NOTES
- PowerPoint needs to be installed to create a PPTX.
+ PPTX report generation uses OpenXML and does not require PowerPoint.
The CSV output is filtered when using WAF to work around some data issues - only nominated pillar findings will be processed.
The Assessment type is attemptedly guessed from the title on the input CSV. If it can't be guessed, WAF is assumed.
@@ -87,7 +90,6 @@
#>
-
[CmdletBinding()]
param (
# Indicates CSV file for input
@@ -109,44 +111,41 @@ param (
[switch] $DevOpsCapability,
[Parameter()]
- [switch] $GenAI
+ [switch] $GenAI,
-)
+ [Parameter()][string]
+ $OutputDirectory = (Get-Location).Path
+)
#region Functions
-function Release-ComObject {
- param(
- [Parameter(Mandatory=$true)]
- [System.__ComObject]$ComObject
- )
-
- try {
- [System.Runtime.InteropServices.Marshal]::ReleaseComObject($ComObject) | Out-Null
- [System.GC]::Collect()
- [System.GC]::WaitForPendingFinalizers()
- }
- catch {
- Write-Warning "Failed to release COM object: $_"
- }
-}
-
+function OpenAssessmentFile {
+ <#
+.SYNOPSIS
+ Opens the assessment CSV content file.
-$assessmentFile = ""
+.DESCRIPTION
+ Resolves the configured content file, or uses the Windows file picker when no file is provided on Windows. Stores the resolved input path in $global:assessmentFile for status output.
-function OpenAssessmentFile {
+.OUTPUTS
+ String array containing the assessment CSV file lines.
+#>
+ $inputFile = $ContentFile
- if ($null -eq $ContentFile -or !(Test-Path $ContentFile)) {
+ if ($null -eq $inputFile -or !(Test-Path $inputFile)) {
+ if (!$IsWindows) {
+ while ($null -eq $inputFile -or !(Test-Path $inputFile)) {
+ $ContentFile = Read-Host "Please provide a valid ContentFile path."
+ }
+ }
$inputFile = Get-FileName $workingDirectory
}
- else {
- $inputFile = $ContentFile
- }
- # validate our file is OK
+ $inputFile = (Resolve-Path -LiteralPath $inputFile).Path
+
try {
- $content = Get-Content $inputFile
+ $content = Get-Content -LiteralPath $inputFile
}
catch {
Write-Error -Message "Unable to open selected Content file."
@@ -154,1119 +153,2099 @@ function OpenAssessmentFile {
}
$global:assessmentFile = $inputFile
- return $content
-
+ return $content
}
-function Get-FileName($initialDirectory) {
- [System.Reflection.Assembly]::LoadWithPartialName("System.windows.forms") | Out-Null
-
- $OpenFileDialog = New-Object System.Windows.Forms.OpenFileDialog
- $OpenFileDialog.initialDirectory = $initialDirectory
- $OpenFileDialog.filter = "CSV (*.csv)| *.csv"
- $OpenFileDialog.Title = "Select review file export"
- $OpenFileDialog.ShowDialog() | Out-Null
- $OpenFileDialog.filename
-}
+function Resolve-ReportOutputDirectory {
+ <#
+.SYNOPSIS
+ Resolves and validates the report output directory.
-function FindIndexBeginningWith($stringset, $searchterm) {
- $i = 0
- foreach ($line in $stringset) {
- if ($line.StartsWith($searchterm)) {
- return $i
- }
- $i++
+.DESCRIPTION
+ Creates the directory when it does not exist, verifies it is a directory, and confirms it is writable with a temporary probe file.
+
+.PARAMETER Path
+ Requested output directory path. Empty input defaults to the current working directory.
+
+.OUTPUTS
+ Fully resolved output directory path.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)][string]$Path
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ $Path = (Get-Location).Path
}
- return false
-}
-function LoadDescriptionFile {
- if ($WellArchitected) {
+ if (-not (Test-Path -LiteralPath $Path)) {
try {
- $descriptionsFile = Import-Csv "$workingDirectory\WAF Category Descriptions.csv"
+ New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
catch {
- Write-Error -Message "Unable to open $($workingDirectory)\WAF Category Descriptions.csv"
+ Write-Error "Unable to create output directory: $Path. $($_.Exception.Message)"
exit
}
}
- elseif ($DevOpsCapability) {
- try {
- $descriptionsFile = Import-Csv "$workingDirectory\DevOps Category Descriptions.csv"
- }
- catch {
- Write-Error -Message "Unable to open $($workingDirectory)\DevOps Category Descriptions.csv"
- exit
- }
+
+ $resolvedPath = (Resolve-Path -LiteralPath $Path).Path
+ if (-not (Test-Path -LiteralPath $resolvedPath -PathType Container)) {
+ Write-Error "OutputDirectory must be a directory: $resolvedPath"
+ exit
}
- elseif ($GenAI) {
- try {
- $descriptionsFile = Import-Csv "$workingDirectory\GenAI Category Descriptions.csv"
- }
- catch {
- Write-Error -Message "Unable to open $($workingDirectory)\GenAI Category Descriptions.csv"
- exit
- }
+
+ $probePath = Join-Path $resolvedPath ".warp-write-test-$([guid]::NewGuid().ToString('N'))"
+ try {
+ Set-Content -LiteralPath $probePath -Value "test" -NoNewline -ErrorAction Stop
+ Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue
}
- else {
- try {
- $descriptionsFile = Import-Csv "$workingDirectory\CAF Category Descriptions.csv"
- }
- catch {
- Write-Error -Message "Unable to open $($workingDirectory)\CAF Category Descriptions.csv"
- exit
- }
+ catch {
+ Write-Error "Output directory is not writable: $resolvedPath. $($_.Exception.Message)"
+ exit
}
- return $descriptionsFile
+ return $resolvedPath
}
-function Get-PillarInfo($pillar) {
- if ($pillar.Contains("Cost Optimization")) {
- return [pscustomobject]@{"Pillar" = $pillar; "Score" = $costScore; "Description" = $costDescription; "ScoreDescription" = $OverallScoreDescription }
+
+function Assert-RequiredFile {
+ <#
+.SYNOPSIS
+ Fails the script when a required file is missing.
+
+.PARAMETER Path
+ Path to the required file.
+
+.PARAMETER Description
+ Human-readable name used in the error message.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Description
+ )
+
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
+ Write-Error "$Description not found: $Path"
+ exit
}
- if ($pillar.Contains("Reliability")) {
- return [pscustomobject]@{"Pillar" = $pillar; "Score" = $reliabilityScore; "Description" = $reliabilityDescription; "ScoreDescription" = $ReliabilityScoreDescription }
+}
+
+<#
+.SYNOPSIS
+ Validates that the expected assessment table was found in the CSV content.
+
+.PARAMETER TableStart
+ Index of the expected CSV header row, or $false when it was not found.
+
+.PARAMETER EndStringIdentifier
+ End marker line for the exported assessment table.
+
+.PARAMETER ExpectedHeader
+ Header string expected for the selected assessment type.
+#>
+function Assert-ContentSection {
+ param(
+ [Parameter(Mandatory = $true)]$TableStart,
+ [AllowNull()][string]$EndStringIdentifier,
+ [Parameter(Mandatory = $true)][string]$ExpectedHeader
+ )
+
+ if ($TableStart -is [bool] -or [string]::IsNullOrWhiteSpace($EndStringIdentifier)) {
+ Write-Error "Input file does not contain the expected assessment table. Expected header: $ExpectedHeader"
+ exit
}
- if ($pillar.Contains("Operational Excellence")) {
- return [pscustomobject]@{"Pillar" = $pillar; "Score" = $operationsScore; "Description" = $operationsDescription; "ScoreDescription" = $OperationsScoreDescription }
+}
+
+
+function Resolve-SafeHyperlinkTarget {
+ <#
+.SYNOPSIS
+ Validates and normalizes recommendation hyperlinks for PPTX output.
+
+.DESCRIPTION
+ Allows only absolute HTTP and HTTPS URLs. Invalid or unsupported targets are skipped with a warning so unsafe schemes are not written to external PPTX relationships.
+
+.PARAMETER Target
+ Hyperlink target read from the assessment CSV.
+
+.OUTPUTS
+ Absolute URI string, or $null when the target is invalid or unsupported.
+#>
+ param([AllowNull()][string]$Target)
+
+ if ([string]::IsNullOrWhiteSpace($Target)) {
+ return $null
}
- if ($pillar.Contains("Performance Efficiency")) {
- return [pscustomobject]@{"Pillar" = $pillar; "Score" = $performanceScore; "Description" = $performanceDescription; "ScoreDescription" = $PerformanceScoreDescription }
+
+ $uri = $null
+ if (-not [System.Uri]::TryCreate($Target.Trim(), [System.UriKind]::Absolute, [ref]$uri)) {
+ Write-Warning "Skipping invalid recommendation hyperlink: $Target"
+ return $null
}
- if ($pillar.Contains("Security")) {
- return [pscustomobject]@{"Pillar" = $pillar; "Score" = $securityScore; "Description" = $securityDescription; "ScoreDescription" = $SecurityScoreDescription }
+
+ if ($uri.Scheme -notin @("https", "http")) {
+ Write-Warning "Skipping unsupported recommendation hyperlink scheme '$($uri.Scheme)': $Target"
+ return $null
}
+
+ return $uri.AbsoluteUri
}
-function GetMappedReportingCategory {
- param (
- $reportingCategrory,
- $currentPillar
+
+function Resolve-PptxRelationshipTargetPath {
+ <#
+.SYNOPSIS
+ Resolves an internal PPTX relationship target to a normalized package path.
+
+.PARAMETER RelationshipPartName
+ Name of the .rels package part that owns the relationship.
+
+.PARAMETER Target
+ Relationship target value to normalize.
+
+.OUTPUTS
+ Normalized package-relative path.
+#>
+ param(
+ [Parameter(Mandatory = $true)][string]$RelationshipPartName,
+ [Parameter(Mandatory = $true)][string]$Target
)
- $newReportingCategory = ($descriptionsFile | Where-Object { $_.Pillar -eq $currentPillar -and $_.Category.StartsWith($reportingCategrory) }).Caption
- if (-not $newReportingCategory) {
- $newReportingCategory = $reportingCategrory # Fallback to existing ReportingCategory if no mapping found
+ if ($RelationshipPartName -eq "_rels/.rels") {
+ $basePath = ""
+ }
+ elseif ($RelationshipPartName -match '^(.*)/_rels/[^/]+\.rels$') {
+ $basePath = $matches[1]
+ }
+ else {
+ $basePath = ""
}
- return $newReportingCategory
+ $combinedPath = if ([string]::IsNullOrWhiteSpace($basePath)) { $Target } else { "$basePath/$Target" }
+ $parts = New-Object System.Collections.Generic.List[string]
+ foreach ($part in ($combinedPath -split '/')) {
+ if ([string]::IsNullOrWhiteSpace($part) -or $part -eq ".") {
+ continue
+ }
+ if ($part -eq "..") {
+ if ($parts.Count -gt 0) {
+ $parts.RemoveAt($parts.Count - 1)
+ }
+ continue
+ }
+ $parts.Add($part)
+ }
+
+ return ($parts -join '/')
}
+function Test-PptxPackage {
+ <#
+.SYNOPSIS
+ Validates a generated PPTX package for basic OpenXML integrity.
-Function WellArchitectedAssessment {
-
- # Capture gauge templates ONCE before processing any pillars
- # This prevents the template slide from being modified
- $redGaugeTemplate = $summarySlide.Shapes[51]
- $yellowGaugeTemplate = $summarySlide.Shapes[50]
- $greenGaugeTemplate = $summarySlide.Shapes[49]
-
- foreach ($pillar in $filteredpillars) {
- $pillarData = $data | Where-Object { $_.Category -eq $pillar }
+.DESCRIPTION
+ Opens the PPTX as a zip package, verifies XML and relationship parts parse, checks internal relationship targets, and detects duplicate relationship IDs.
- $pillarInfo = Get-PillarInfo -pillar $pillar
-
- # Populates Title Slide
- $slideTitle = $title.Replace("[pillar]", $pillar)
- $newTitleSlide = $titleSlide.Duplicate()
- $newTitleSlide.MoveTo($presentation.Slides.Count)
- $newTitleSlide.Shapes[3].TextFrame.TextRange.Text = $slideTitle
- $newTitleSlide.Shapes[4].TextFrame.TextRange.Text = $newTitleSlide.Shapes[4].TextFrame.TextRange.Text.Replace("[Report_Date]", $localReportDate)
-
- # Populates Executive Summary Slide(s)
- # prepare category list and identify "high importance" recommendations
-
- $CategoriesList = New-Object System.Collections.ArrayList
- $categories = ($pillarData | Sort-Object -Property "Weight" -Descending).ReportingCategory | Select-Object -Unique
- foreach ($category in $categories) {
- $categoryWeight = ($pillarData | Where-Object { $_.ReportingCategory -eq $category }).Weight | Measure-Object -Sum
- $categoryScore = $categoryWeight.Sum / $categoryWeight.Count
- $categoryWeightiestCount = ($pillarData | Where-Object { $_.ReportingCategory -eq $category }).Weight -ge $MinimumReportLevel | Measure-Object
- $CategoriesList.Add([pscustomobject]@{"Category" = $category; "CategoryScore" = $categoryScore; "CategoryWeightiestCount" = $categoryWeightiestCount.Count }) | Out-Null
- }
-
- # display categories alphabetically - so that the WAF 2.0 code numbers are in order
- $CategoriesList = $CategoriesList | Sort-Object -Property Category
-
- $newSummarySlide = $summarySlide.Duplicate()
- $newSummarySlide.MoveTo($presentation.Slides.Count)
- $newSummarySlide.Shapes[3].TextFrame.TextRange.Text = $pillarInfo.Score
- $newSummarySlide.Shapes[4].TextFrame.TextRange.Text = $pillarInfo.Description
- [Double]$summBarScore = [int]$pillarInfo.Score * 2.47 + 56
- $newSummarySlide.Shapes[11].Left = $summBarScore
-
- $counter = 13 #Shape index for the slide to start adding scores
- $categoryCounter = 0
- $pageCounter = 1
- $gaugeIconX = 437.76
- $gaugeIconY = @(147.6, 176.4, 204.48, 232.56, 261.36, 289.44, 317.52, 346.32, 375.12, 403.2, 432.0, 460.08)
-
- # Filter out any empty / non-existing categories including "Uncategorized" (aka Advisor)
- $FilteredCategoriesList = ($CategoriesList | Where-Object { $_.Category -ne "" -and $_.Category -ne "Uncategorized" })
- $CategoriesList = $FilteredCategoriesList
-
- foreach ($category in $CategoriesList) {
- if ($categoryCounter -ge (12 * $pageCounter)) {
- # add another page if there are more categories than can fit
- $newSummarySlide = $summarySlide.Duplicate()
- $newSummarySlide.MoveTo($presentation.Slides.Count)
- $newSummarySlide.Shapes[3].TextFrame.TextRange.Text = $pillarInfo.Score
- $newSummarySlide.Shapes[4].TextFrame.TextRange.Text = $pillarInfo.Description
- [Double]$summBarScore = [int]$pillarInfo.Score * 2.47 + 56
- $newSummarySlide.Shapes[11].Left = $summBarScore
-
- $counter = 13 #Shape count for the slide to start adding scores
- $categoryCounter = 0
- $pageCounter = $pageCounter + 1
- $gaugeIconX = 437.76
- $gaugeIconY = @(147.6, 176.4, 204.48, 232.56, 261.36, 289.44, 317.52, 346.32, 375.12, 403.2, 432.0, 460.08)
+.PARAMETER Path
+ PPTX file path to validate.
+
+.OUTPUTS
+ Array of validation issue strings. Empty array means no issues were found.
+#>
+ param(
+ [Parameter(Mandatory = $true)][string]$Path
+ )
+
+ $issues = New-Object System.Collections.Generic.List[string]
+ if (-not (Test-Path -LiteralPath $Path)) {
+ $issues.Add("Package not found: $Path")
+ return $issues.ToArray()
+ }
+
+ $archive = $null
+ try {
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($Path)
+ }
+ catch {
+ $issues.Add("Unable to open package as zip: $Path. $($_.Exception.Message)")
+ return $issues.ToArray()
+ }
+
+ try {
+ $entryNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($entry in $archive.Entries) {
+ [void]$entryNames.Add($entry.FullName)
+ }
+
+ foreach ($entry in $archive.Entries) {
+ if ($entry.FullName -notmatch '\.(xml|rels)$') {
+ continue
}
+ $reader = [System.IO.StreamReader]::new($entry.Open())
try {
- $newSummarySlide.Shapes[$counter].TextFrame.TextRange.Text = $category.CategoryWeightiestCount.ToString("0")
-
- # Replacing the domain area (aka category) with the caption (aka new category) from the description file (if any)
- $newSummarySlide.Shapes[$counter + 1].TextFrame.TextRange.Text = GetmappedReportingCategory -reportingCategrory $category.Category -currentPillar $pillar
-
- $counter = $counter + 3 # select the next score textbox shape on the slide
-
- # Determining the color based on CategoryScore
- # Use the saved template references instead of shapes from newSummarySlide
- switch ($category.CategoryScore) {
- { $_ -lt 33 } {
- $categoryShape = $greenGaugeTemplate
- break
- }
- { $_ -gt 33 -and $_ -lt 67 } {
- $categoryShape = $yellowGaugeTemplate
- break
- }
- { $_ -gt 67 } {
- $categoryShape = $redGaugeTemplate
- break
- }
- Default {
- $categoryShape = $yellowGaugeTemplate
- }
- }
-
- $categoryShape.Duplicate() | Out-Null
- $newShape = $newSummarySlide.Shapes.Count
- $newSummarySlide.Shapes[$newShape].Left = $gaugeIconX
- $newSummarySlide.Shapes[$newShape].top = $gaugeIconY[$categoryCounter]
- $newSummarySlide.Shapes[$newShape].Name = "NewGauges"
+ $entryText = $reader.ReadToEnd()
+ }
+ finally {
+ $reader.Dispose()
+ }
- $categoryCounter = $categoryCounter + 1
+ try {
+ [xml]$entryXml = $entryText
}
catch {
- Write-Warning "Error processing category: $($_.Exception.Message)"
+ $issues.Add("Invalid XML in $($entry.FullName): $($_.Exception.Message)")
continue
}
- }
-
- # Populates Pillar Slides
- foreach ($category in $CategoriesList.Category) {
- $categoryData = $pillarData | Where-Object { $_.ReportingCategory -eq $category -and $_.Category -eq $pillar }
- $categoryDataCount = ($categoryData | measure).Count
- $categoryWeight = ($pillarData | Where-Object { $_.ReportingCategory -eq $category }).Weight | Measure-Object -Sum
- $categoryScore = $categoryWeight.Sum / $categoryWeight.Count
- $categoryDescription = ($descriptionsFile | Where-Object { $_.Pillar -eq $pillar -and $_.Category.StartsWith($category) }).Description
- # Replacing the domain area (aka category) with the caption (aka new category) from the description file (if any)
- $categoryTitle = GetmappedReportingCategory -reportingCategrory $category -currentPillar $pillar
-
- $y = $categoryDataCount
- $x = $ShowTop
- if ($categoryDataCount -lt $x) {
- $x = $categoryDataCount
+ if ($entry.FullName -notmatch '\.rels$') {
+ continue
}
- $newDetailSlide = $detailSlide.Duplicate()
- $newDetailSlide.MoveTo($presentation.Slides.Count)
-
- $newDetailSlide.Shapes[1].TextFrame.TextRange.Text = $categoryTitle
- $newDetailSlide.Shapes[3].TextFrame.TextRange.Text = $categoryScore.ToString("#")
- [Double]$detailBarScore = $categoryScore * 2.48 + 38
- $newDetailSlide.Shapes[12].Left = $detailBarScore
- $newDetailSlide.Shapes[4].TextFrame.TextRange.Text = $categoryDescription
- $newDetailSlide.Shapes[7].TextFrame.TextRange.Text = "Top $x out of $y recommendations:"
- $sortedRecommendations = $categoryData | Sort-Object -Property "Link-Text" -Unique | Sort-Object -Property Weight -Descending | Select-Object -First $x
- $trimmedText = $sortedRecommendations | ForEach-Object { $_.'Link-Text'.Trim() }
- $newDetailSlide.Shapes[8].TextFrame.TextRange.Text = $trimmedText -join "`r`n`r`n"
-
- $lastFoundRange = $null
- foreach ($rec in $sortedRecommendations) {
- $recText = $rec.'Link-Text'.Trim()
- try {
- if ($null -eq $lastFoundRange) {
- $textRange = $newDetailSlide.Shapes[8].TextFrame.TextRange.Find($recText)
- }
- else {
- $textRange = $newDetailSlide.Shapes[8].TextFrame.TextRange.Find($recText, $lastFoundRange.Start + $lastFoundRange.Length)
- }
- if ($textRange) {
- $textRange.ActionSettings(1).HyperLink.Address = $rec.Link
- $lastFoundRange = $textRange
- }
- else {
- Write-Warning "Could not find text in shape: $recText"
- }
+ $relationshipIds = @()
+ foreach ($relationship in $entryXml.Relationships.Relationship) {
+ $relationshipIds += [string]$relationship.Id
+ if ([string]$relationship.TargetMode -eq "External") {
+ continue
}
- catch {
- Write-Warning "Failed to set hyperlink for: $recText - Error: $_"
+ if ([string]::IsNullOrWhiteSpace([string]$relationship.Target)) {
+ continue
}
- }
- }
- #Remove boilerplate shapes.
- if ($categories.Count -lt (12 * $pageCounter)) {
- #12 is the number of categories shapes on the summary slide
- for ($k = $newSummarySlide.Shapes.count; $k -gt $counter - 1; $k--) {
- if ($null -ne $newSummarySlide.Shapes[$k] -and $newSummarySlide.Shapes[$k].Name -ne "NewGauges") {
- # Don't delete the newly added colored gauge shapes
- try {
- $newSummarySlide.Shapes[$k].Delete()
- }
- catch {}
+ $resolvedTarget = Resolve-PptxRelationshipTargetPath -RelationshipPartName $entry.FullName -Target ([string]$relationship.Target)
+ if (-not $entryNames.Contains($resolvedTarget)) {
+ $issues.Add("Unresolved relationship $($entry.FullName)#$($relationship.Id) -> $($relationship.Target)")
}
}
+
+ $duplicateRelationshipIds = $relationshipIds | Group-Object | Where-Object { $_.Count -gt 1 }
+ foreach ($duplicateRelationshipId in $duplicateRelationshipIds) {
+ $issues.Add("Duplicate relationship id $($duplicateRelationshipId.Name) in $($entry.FullName)")
+ }
+ }
+ }
+ finally {
+ if ($null -ne $archive) {
+ $archive.Dispose()
}
}
+
+ return $issues.ToArray()
}
-Function CloudAdoptionAssessment {
- $slideTitle = $title.Replace("[CAF_Security_Assessment]", "Cloud Adoption Security Assessment")
- $newTitleSlide = $titleSlide.Duplicate()
- $newTitleSlide.MoveTo($presentation.Slides.Count)
- $newTitleSlide.Shapes[3].TextFrame.TextRange.Text = $slideTitle
- $newTitleSlide.Shapes[4].TextFrame.TextRange.Text = $newTitleSlide.Shapes[4].TextFrame.TextRange.Text.Replace("[Report_Date]", $localReportDate)
- # Edit Executive Summary Slide
- if (![string]::IsNullOrEmpty($overallScore)) {
- $ScoreText = "$($overallScore)"
- }
+function Export-AssessmentReportPptxOpenXml {
+ <#
+.SYNOPSIS
+ Generates a PowerPoint assessment report using OpenXML package editing.
+
+.DESCRIPTION
+ Copies report slides from the selected template, fills title, summary, detail, hyperlink, gauge, and closing slides, removes template slides, writes a PPTX package, and validates the result.
- # Add logic to get overall score
- $newSummarySlide = $summarySlide.Duplicate()
- $newSummarySlide.MoveTo($presentation.Slides.Count)
- $newSummarySlide.Shapes[3].TextFrame.TextRange.Text = $ScoreText
- $newSummarySlide.Shapes[4].TextFrame.TextRange.Text = $cloudAdoptionDescription
- [Double]$summBarScore = [int]$ScoreText * 2.47 + 56
- $newSummarySlide.Shapes[11].Left = $summBarScore
+.PARAMETER WorkingDirectory
+ Directory where the generated PPTX should be written.
+.PARAMETER ReportDate
+ Timestamp used in output file names.
- $CategoriesList = New-Object System.Collections.ArrayList
- #Updated to use ReportingCategory vs Category due to Category column for CASA containing multiple instances of varying interests vs WASA(ie. "Security")
- $categories = $data.ReportingCategory | Sort-Object -Property "Weight" -Descending | Select-Object -Unique
-
-
- # Remove non existing (aka empty) categories. CASA has only 6 categories (no Advisor/uncategorized category)
- $FilteredCategoriesList = [System.Collections.ArrayList]($categories | Where-Object { $_ -ne "" })
- $categories = $FilteredCategoriesList
-
- foreach ($category in $categories) {
- $categoryWeight = ($data | Where-Object { $_.ReportingCategory -eq $category }).Weight | Measure-Object -Sum
- $categoryScore = $categoryWeight.Sum / $categoryWeight.Count
- $categoryWeightiestCount = ($data | Where-Object { $_.ReportingCategory -eq $category }).Weight -ge $MinimumReportLevel | Measure-Object
- $CategoriesList.Add([pscustomobject]@{"Category" = $category; "CategoryScore" = $categoryScore; "CategoryWeightiestCount" = $categoryWeightiestCount.Count }) | Out-Null
+.PARAMETER LocalReportDate
+ Human-readable report date inserted into slides.
+
+.PARAMETER TemplatePresentation
+ Source PPTX template path.
+
+.PARAMETER AssessmentKind
+ Assessment template and grouping mode to use.
+
+.PARAMETER AssessmentTitle
+ Title text for the generated report.
+
+.PARAMETER SummaryDescription
+ Summary description text for non-WAF report types.
+
+.PARAMETER OverallScore
+ Overall score inserted into summary slides.
+
+.PARAMETER Data
+ Parsed assessment recommendation rows.
+
+.PARAMETER ShowTop
+ Maximum recommendations to include per detail slide.
+
+.PARAMETER MinimumReportLevel
+ Minimum recommendation weight counted as high severity.
+
+.OUTPUTS
+ Boolean indicating whether PPTX generation and validation succeeded.
+#>
+ param (
+ [Parameter(Mandatory = $true)][string]$WorkingDirectory,
+ [Parameter(Mandatory = $true)][string]$ReportDate,
+ [Parameter(Mandatory = $true)][string]$LocalReportDate,
+ [Parameter(Mandatory = $true)][string]$TemplatePresentation,
+ [Parameter()][ValidateSet("WAF", "CASA", "DevOps", "GenAI")][string]$AssessmentKind = "WAF",
+ [Parameter()][string]$AssessmentTitle = "",
+ [Parameter()][string]$SummaryDescription = "",
+ [Parameter()][string]$OverallScore,
+ [Parameter()][array]$Data,
+ [Parameter()][int]$ShowTop = 3,
+ [Parameter()][int]$MinimumReportLevel = 65
+ )
+
+ Write-Host "Using OpenXML PPTX generation path..." -ForegroundColor Yellow
+
+ $wafTemplateMap = @{
+ TitleSlide = @{
+ SourceSlide = 9
+ PillarName = "WAF_Title_PillarName"
+ ReportDate = "WAF_Title_ReportDate"
+ }
+ SummarySlide = @{
+ SourceSlide = 10
+ Score = "WAF_Summary_Score"
+ Description = "WAF_Summary_PillarDescription"
+ ScoreIndicator = "WAF_Summary_ScoreIndicator"
+ Rows = @(
+ @{ Count = "WAF_Summary_Row01_Count"; Label = "WAF_Summary_Row01_Label"; Gauge = "WAF_Summary_Row01_Gauge" }
+ @{ Count = "WAF_Summary_Row02_Count"; Label = "WAF_Summary_Row02_Label"; Gauge = "WAF_Summary_Row02_Gauge" }
+ @{ Count = "WAF_Summary_Row03_Count"; Label = "WAF_Summary_Row03_Label"; Gauge = "WAF_Summary_Row03_Gauge" }
+ @{ Count = "WAF_Summary_Row04_Count"; Label = "WAF_Summary_Row04_Label"; Gauge = "WAF_Summary_Row04_Gauge" }
+ @{ Count = "WAF_Summary_Row05_Count"; Label = "WAF_Summary_Row05_Label"; Gauge = "WAF_Summary_Row05_Gauge" }
+ @{ Count = "WAF_Summary_Row06_Count"; Label = "WAF_Summary_Row06_Label"; Gauge = "WAF_Summary_Row06_Gauge" }
+ @{ Count = "WAF_Summary_Row07_Count"; Label = "WAF_Summary_Row07_Label"; Gauge = "WAF_Summary_Row07_Gauge" }
+ @{ Count = "WAF_Summary_Row08_Count"; Label = "WAF_Summary_Row08_Label"; Gauge = "WAF_Summary_Row08_Gauge" }
+ @{ Count = "WAF_Summary_Row09_Count"; Label = "WAF_Summary_Row09_Label"; Gauge = "WAF_Summary_Row09_Gauge" }
+ @{ Count = "WAF_Summary_Row10_Count"; Label = "WAF_Summary_Row10_Label"; Gauge = "WAF_Summary_Row10_Gauge" }
+ @{ Count = "WAF_Summary_Row11_Count"; Label = "WAF_Summary_Row11_Label"; Gauge = "WAF_Summary_Row11_Gauge" }
+ @{ Count = "WAF_Summary_Row12_Count"; Label = "WAF_Summary_Row12_Label"; Gauge = "WAF_Summary_Row12_Gauge" }
+ )
+ GaugeRelationships = @{
+ Green = "rId12"
+ Yellow = "rId13"
+ Red = "rId14"
+ }
+ TemplateGauges = @("WAF_Gauge_GreenTemplate", "WAF_Gauge_YellowTemplate", "WAF_Gauge_RedTemplate")
+ }
+ DetailSlide = @{
+ SourceSlide = 11
+ Title = "WAF_Detail_Title"
+ Score = "WAF_Detail_Score"
+ Description = "WAF_Detail_Description"
+ Header = "WAF_Detail_RecommendationHeader"
+ Recommendations = "WAF_Detail_Recommendations"
+ ScoreIndicator = "WAF_Detail_ScoreIndicator"
+ }
+ EndSlide = @{
+ SourceSlide = 12
+ }
}
- $CategoriesList = $CategoriesList | Sort-Object -Property CategoryScore -Descending
+ $casaTemplateMap = @{
+ TitleSlide = @{
+ SourceSlide = 3
+ PillarName = "CASA_Title_AssessmentName"
+ ReportDate = "CASA_Title_ReportDate"
+ }
+ SummarySlide = @{
+ SourceSlide = 4
+ Score = "CASA_Summary_Score"
+ Description = "CASA_Summary_Description"
+ ScoreIndicator = "CASA_Summary_ScoreIndicator"
+ Rows = @(
+ @{ Count = "CASA_Summary_Row01_Count"; Label = "CASA_Summary_Row01_Label"; Gauge = "CASA_Summary_Row01_Gauge" }
+ @{ Count = "CASA_Summary_Row02_Count"; Label = "CASA_Summary_Row02_Label"; Gauge = "CASA_Summary_Row02_Gauge" }
+ @{ Count = "CASA_Summary_Row03_Count"; Label = "CASA_Summary_Row03_Label"; Gauge = "CASA_Summary_Row03_Gauge" }
+ @{ Count = "CASA_Summary_Row04_Count"; Label = "CASA_Summary_Row04_Label"; Gauge = "CASA_Summary_Row04_Gauge" }
+ @{ Count = "CASA_Summary_Row05_Count"; Label = "CASA_Summary_Row05_Label"; Gauge = "CASA_Summary_Row05_Gauge" }
+ @{ Count = "CASA_Summary_Row06_Count"; Label = "CASA_Summary_Row06_Label"; Gauge = "CASA_Summary_Row06_Gauge" }
+ @{ Count = "CASA_Summary_Row07_Count"; Label = "CASA_Summary_Row07_Label"; Gauge = "CASA_Summary_Row07_Gauge" }
+ @{ Count = "CASA_Summary_Row08_Count"; Label = "CASA_Summary_Row08_Label"; Gauge = "CASA_Summary_Row08_Gauge" }
+ )
+ TemplateGauges = @("CASA_Gauge_Template01", "CASA_Gauge_Template02", "CASA_Gauge_Template03")
+ }
+ DetailSlide = @{
+ SourceSlide = 5
+ Title = "CASA_Detail_Title"
+ Score = "CASA_Detail_Score"
+ Description = "CASA_Detail_Description"
+ Header = "CASA_Detail_RecommendationHeader"
+ Recommendations = "CASA_Detail_Recommendations"
+ ScoreIndicator = "CASA_Detail_ScoreIndicator"
+ }
+ EndSlide = @{
+ SourceSlide = 6
+ }
+ }
- $counter = 13 #Shape count for the slide to start adding scores
- $categoryCounter = 0
- $gaugeIconX = 437.76 # X coordinate for the gauge icon in points (1 point = 1/72 inch)
- $gaugeIconY = @(147.6, 176.4, 204.48, 232.56, 261.36, 289.44, 317.52, 346.32, 375.12, 403.2, 432.0, 460.08 ) # Y coordinates for the remaining 12 gauge icons in points (vertically aligned)
+ $genAiRows = for ($rowIndex = 1; $rowIndex -le 12; $rowIndex++) {
+ @{ Count = "Count_$rowIndex"; Label = "Name_$rowIndex"; Gauge = "Gauge_$rowIndex" }
+ }
- foreach ($category in $CategoriesList) {
- if ($category.Category -ne "Uncategorized") {
- try {
- #$newSummarySlide.Shapes[8] #Domain 1 Icon
- #$newSummarySlide.Shapes[$counter].TextFrame.TextRange.Text = $category.CategoryScore.ToString("#")
- $newSummarySlide.Shapes[$counter].TextFrame.TextRange.Text = $category.CategoryWeightiestCount.ToString("#")
- $newSummarySlide.Shapes[$counter + 1].TextFrame.TextRange.Text = $category.Category
- $counter = $counter + 3 # no graphic anymore
- # Determining the color based on CategoryScore
- switch ($category.CategoryScore) {
- { $_ -lt 33 } {
- $categoryShape = $newSummarySlide.Shapes[49] #green
- break
- }
- { $_ -gt 33 -and $_ -lt 67 } {
- $categoryShape = $newSummarySlide.Shapes[50] #yellow
- break
- }
- { $_ -gt 67 } {
- $categoryShape = $newSummarySlide.Shapes[51] #red
- break
- }
- Default {
- $categoryShape = $newSummarySlide.Shapes[50] #yellow
- }
- }
- $categoryShape.Duplicate() | Out-Null
- $newShape = $newSummarySlide.Shapes.Count
- $newSummarySlide.Shapes[$newShape].Left = $gaugeIconX
- $newSummarySlide.Shapes[$newShape].top = $gaugeIconY[$categoryCounter]
- # Mark newly added gauge shapes so cleanup logic can remove unused placeholders without touching these.
- $newSummarySlide.Shapes[$newShape].Name = "NewGauges"
- $categoryCounter = $categoryCounter + 1
+ $devOpsTemplateMap = @{
+ TitleSlide = @{
+ SourceSlide = 3
+ PillarName = "TextBox 6"
+ ReportDate = "TextBox 7"
+ }
+ SummarySlide = @{
+ SourceSlide = 4
+ Score = "TextBox 7"
+ Description = "TextBox 8"
+ ScoreIndicator = "Straight Connector 22"
+ Rows = @(
+ @{ Count = "TextBox 15"; Label = "TextBox 17"; Gauge = "Picture 19" }
+ @{ Count = "TextBox 21"; Label = "TextBox 24"; Gauge = "Picture 25" }
+ @{ Count = "TextBox 40"; Label = "TextBox 42"; Gauge = "Picture 44" }
+ @{ Count = "TextBox 46"; Label = "TextBox 48"; Gauge = "Picture 50" }
+ @{ Count = "TextBox 52"; Label = "TextBox 54"; Gauge = "Picture 56" }
+ @{ Count = "TextBox 58"; Label = "TextBox 60"; Gauge = "Picture 62" }
+ @{ Count = "TextBox 2"; Label = "TextBox 4"; Gauge = "Picture 10" }
+ @{ Count = "TextBox 11"; Label = "TextBox 12"; Gauge = "Picture 13" }
+ )
+ GaugeRelationships = @{
+ Green = "rId4"
+ Yellow = "rId5"
+ Red = "rId6"
}
- catch {}
+ TemplateGauges = @("Picture 20", "Picture 29", "Picture 26")
+ }
+ DetailSlide = @{
+ SourceSlide = 5
+ Title = "TextBox 3"
+ Score = "TextBox 7"
+ Description = "TextBox 8"
+ Header = "TextBox 4"
+ Recommendations = "TextBox 9"
+ ScoreIndicator = "Straight Connector 17"
+ }
+ EndSlide = @{
+ SourceSlide = 6
}
}
- # Remove unused placeholder rows on the summary slide.
- # The template contains a fixed number of placeholder rows; for GenAI we only want the rows we populated.
- if ($CategoriesList.Count -lt $gaugeIconY.Count) {
- for ($k = $newSummarySlide.Shapes.Count; $k -gt $counter - 1; $k--) {
- if ($null -ne $newSummarySlide.Shapes[$k] -and $newSummarySlide.Shapes[$k].Name -ne "NewGauges") {
- try { $newSummarySlide.Shapes[$k].Delete() } catch {}
+ $genAiTemplateMap = @{
+ TitleSlide = @{
+ SourceSlide = 3
+ PillarName = "Title"
+ ReportDate = "ReportDate"
+ }
+ SummarySlide = @{
+ SourceSlide = 4
+ Score = "ScoreText"
+ Description = "PillarDescription"
+ ScoreIndicator = "ScoreIndicator"
+ Rows = @($genAiRows)
+ GaugeRelationships = @{
+ Green = "rId5"
+ Yellow = "rId6"
+ Red = "rId7"
}
+ TemplateGauges = @("Gauge_Green", "Gauge_Yellow", "Gauge_Red")
+ }
+ DetailSlide = @{
+ SourceSlide = 5
+ Title = "CategoryLabel"
+ Score = "CategoryScore"
+ Description = "CategoryDescription"
+ Header = "TOPRecommendations"
+ Recommendations = "TextBox 9"
+ ScoreIndicator = "ScoreIndicator"
+ }
+ EndSlide = @{
+ SourceSlide = 6
}
}
+ $templateMap = switch ($AssessmentKind) {
+ "CASA" { $casaTemplateMap }
+ "DevOps" { $devOpsTemplateMap }
+ "GenAI" { $genAiTemplateMap }
+ default { $wafTemplateMap }
+ }
+ if ([string]::IsNullOrWhiteSpace($AssessmentTitle)) {
+ $AssessmentTitle = switch ($AssessmentKind) {
+ "CASA" { "Cloud Adoption Security Assessment" }
+ "GenAI" { "GenAI Workload Security Assessment" }
+ "DevOps" { "DevOps Capability Review" }
+ default { "Well-Architected Assessment" }
+ }
+ }
+ $normalizedTemplatePresentation = $TemplatePresentation
+ if (-not (Test-Path -LiteralPath $normalizedTemplatePresentation)) {
+ $normalizedTemplatePresentation = $TemplatePresentation -replace '\\', '/'
+ }
- #Remove the boilerplate placeholder text if categories < 8
- if ($categories.Count -lt 8) {
- for ($k = $newSummarySlide.Shapes.count; $k -gt $counter - 1; $k--) {
- try {
- $newSummarySlide.Shapes[$k].Delete()
- $newSummarySlide.Shapes[$k + 1].Delete()
+ if (-not (Test-Path -LiteralPath $normalizedTemplatePresentation)) {
+ Write-Error "Template file not found: $TemplatePresentation"
+ return $false
+ }
+
+ $resolvedTemplatePresentation = (Resolve-Path -LiteralPath $normalizedTemplatePresentation).Path
+
+
+ function New-UniqueReportOutputPath {
+ <#
+ .SYNOPSIS
+ Builds a unique PPTX output path for the selected assessment kind.
+
+ .PARAMETER Directory
+ Output directory for the generated report.
+
+ .PARAMETER DateStamp
+ Timestamp string used in the output file name.
+
+ .PARAMETER Kind
+ Assessment kind used to choose the output file prefix.
+
+ .OUTPUTS
+ PPTX output path that does not currently exist.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$Directory,
+ [Parameter(Mandatory = $true)][string]$DateStamp,
+ [Parameter(Mandatory = $true)][ValidateSet("WAF", "CASA", "DevOps", "GenAI")][string]$Kind
+ )
+
+ $safeReportDate = ($DateStamp.ToString() -replace '[\\/:\*\?"<>\|]', '-')
+ $baseFileName = switch ($Kind) {
+ "WAF" { "WAF-Review-$safeReportDate" }
+ "CASA" { "CASA-$safeReportDate" }
+ "DevOps" { "DevOps-$safeReportDate" }
+ "GenAI" { "GenAI-$safeReportDate" }
+ }
+
+ $candidatePath = Join-Path $Directory "$baseFileName.pptx"
+ $suffix = 2
+ while (Test-Path -LiteralPath $candidatePath) {
+ $candidatePath = Join-Path $Directory "$baseFileName-$suffix.pptx"
+ $suffix++
+ }
+
+ return $candidatePath
+ }
+
+ $outputPath = New-UniqueReportOutputPath -Directory $WorkingDirectory -DateStamp $ReportDate -Kind $AssessmentKind
+ $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "waf-pptx-$ReportDate-$([guid]::NewGuid().ToString('N'))"
+
+ <#
+ .SYNOPSIS
+ Escapes text for use in OpenXML text nodes.
+
+ .PARAMETER Text
+ Text value to escape. Null becomes an empty string.
+
+ .OUTPUTS
+ XML-escaped text.
+ #>
+ function Get-OpenXmlEscapedText {
+ param([AllowNull()][string]$Text)
+ if ($null -eq $Text) { return "" }
+ return [System.Security.SecurityElement]::Escape($Text)
+ }
+
+
+ function New-PptxTextBodyXml {
+ <#
+ .SYNOPSIS
+ Creates a PPTX text body XML fragment.
+
+ .DESCRIPTION
+ Preserves useful body, paragraph, and run properties from an existing text body while replacing its text content and selected formatting.
+
+ .PARAMETER Text
+ Text to insert into the shape.
+
+ .PARAMETER FontSize
+ OpenXML font size value.
+
+ .PARAMETER Bold
+ Indicates whether generated text should be bold.
+
+ .PARAMETER Alignment
+ Optional paragraph alignment value.
+
+ .PARAMETER TemplateTextBodyXml
+ Existing text body XML used as a formatting source.
+
+ .OUTPUTS
+ Replacement p:txBody XML fragment.
+ #>
+ param(
+ [AllowNull()][string]$Text,
+ [string]$FontSize = "1800",
+ [bool]$Bold = $false,
+ [AllowNull()][string]$Alignment,
+ [AllowNull()][string]$TemplateTextBodyXml
+ )
+
+ $lines = @(([string]$Text) -split "`r`n|`n|`r")
+ if ($lines.Count -eq 0) {
+ $lines = @("")
+ }
+
+ $bodyPrXml = ''
+ $lstStyleXml = ''
+ $paragraphProperties = ""
+ $runProperties = ''
+
+ if (-not [string]::IsNullOrWhiteSpace($TemplateTextBodyXml)) {
+ $bodyPrMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($bodyPrMatch.Success) { $bodyPrXml = $bodyPrMatch.Value }
+
+ $lstStyleMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($lstStyleMatch.Success) { $lstStyleXml = $lstStyleMatch.Value }
+
+ $paragraphPropertiesMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($paragraphPropertiesMatch.Success) { $paragraphProperties = $paragraphPropertiesMatch.Value }
+
+ $runPropertiesMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($runPropertiesMatch.Success) { $runProperties = $runPropertiesMatch.Value }
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($Alignment)) {
+ if ([string]::IsNullOrWhiteSpace($paragraphProperties)) {
+ $paragraphProperties = ""
+ }
+ elseif ($paragraphProperties -match '\balgn="[^"]*"') {
+ $paragraphProperties = [regex]::Replace($paragraphProperties, '\balgn="[^"]*"', "algn=`"$Alignment`"", 1)
+ }
+ else {
+ $paragraphProperties = [regex]::Replace($paragraphProperties, '$paragraphProperties$runProperties$(Get-OpenXmlEscapedText $line)"
+ }
+
+ return "$bodyPrXml$lstStyleXml$($paragraphs -join '')"
}
- # Edit new category summary slide
- foreach ($category in $CategoriesList.Category) {
+ function New-PptxHyperlinkTextBodyXml {
+ <#
+ .SYNOPSIS
+ Creates a PPTX text body XML fragment containing hyperlink runs.
+
+ .PARAMETER Items
+ Objects with Text and RelationshipId properties.
+
+ .PARAMETER FontSize
+ OpenXML font size value.
+
+ .PARAMETER TemplateTextBodyXml
+ Existing text body XML used as a formatting source.
+
+ .OUTPUTS
+ Replacement p:txBody XML fragment.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][array]$Items,
+ [string]$FontSize = "1200",
+ [AllowNull()][string]$TemplateTextBodyXml
+ )
+
+ $bodyPrXml = ''
+ $lstStyleXml = ''
+ $paragraphProperties = ""
+ $runProperties = ''
+
+ if (-not [string]::IsNullOrWhiteSpace($TemplateTextBodyXml)) {
+ $bodyPrMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($bodyPrMatch.Success) { $bodyPrXml = $bodyPrMatch.Value }
+
+ $lstStyleMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($lstStyleMatch.Success) { $lstStyleXml = $lstStyleMatch.Value }
+
+ $paragraphPropertiesMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($paragraphPropertiesMatch.Success) { $paragraphProperties = $paragraphPropertiesMatch.Value }
- $categoryData = $data | Where-Object { $_.ReportingCategory -eq $category }
- $categoryDataCount = ($categoryData | Measure-Object).Count
- $categoryWeight = ($data | Where-Object { $_.ReportingCategory -eq $category }).Weight | Measure-Object -Sum
- $categoryScore = $categoryWeight.Sum / $categoryWeight.Count
- $categoryDescription = ($descriptionsFile | Where-Object { $categoryData.ReportingCategory.Contains($_.Category) }).Description
- $y = $categoryDataCount
- $x = $ShowTop
- if ($categoryDataCount -lt $x) {
- $x = $categoryDataCount
+ $runPropertiesMatch = [regex]::Match($TemplateTextBodyXml, '(?s)|]*/>')
+ if ($runPropertiesMatch.Success) { $runProperties = $runPropertiesMatch.Value }
}
- $newDetailSlide = $detailSlide.Duplicate()
- $newDetailSlide.MoveTo($presentation.Slides.Count)
+ if (-not [string]::IsNullOrWhiteSpace($FontSize)) {
+ if ($runProperties -match '\bsz="[^"]*"') {
+ $runProperties = [regex]::Replace($runProperties, '\bsz="[^"]*"', "sz=`"$FontSize`"", 1)
+ }
+ else {
+ $runProperties = [regex]::Replace($runProperties, ']*/>', '')
+ if ($itemRunProperties -match '/>$') {
+ $itemRunProperties = [regex]::Replace($itemRunProperties, '/>$', ">")
}
else {
- $textRange = $newDetailSlide.Shapes[8].TextFrame.TextRange.Find($recText, $lastFoundRange.Start + $lastFoundRange.Length)
- }
- if ($textRange) {
- $textRange.ActionSettings(1).HyperLink.Address = $rec.Link
- $lastFoundRange = $textRange
- }
- else {
- Write-Warning "Could not find text in shape: $recText"
+ $itemRunProperties = [regex]::Replace($itemRunProperties, '', "", 1)
}
}
- catch {
- Write-Warning "Failed to set hyperlink for: $recText - Error: $_"
- }
+ "$paragraphProperties$itemRunProperties$(Get-OpenXmlEscapedText $item.Text)"
}
+
+ return "$bodyPrXml$lstStyleXml$($paragraphs -join '')"
}
-}
-Function DevOpsCapabilityAssessment {
- $slideTitle = $title.Replace("[CA_Security_Review]", "DevOps Capability Review")
- $newTitleSlide = $titleSlide.Duplicate()
- $newTitleSlide.MoveTo($presentation.Slides.Count)
- $newTitleSlide.Shapes[3].TextFrame.TextRange.Text = $slideTitle
- $newTitleSlide.Shapes[4].TextFrame.TextRange.Text = $newTitleSlide.Shapes[4].TextFrame.TextRange.Text.Replace("[Report_Date]", $localReportDate)
- # Edit Executive Summary Slide
- if (![string]::IsNullOrEmpty($overallScore)) {
- $ScoreText = "$($overallScore)"
+ function Set-PptxShapeTextBodyByName {
+ <#
+ .SYNOPSIS
+ Replaces a named PPTX shape's text body XML.
+
+ .PARAMETER SlideXml
+ Slide XML containing the named shape.
+
+ .PARAMETER ShapeName
+ Shape name to find.
+
+ .PARAMETER TextBodyXml
+ Replacement p:txBody XML.
+
+ .OUTPUTS
+ Updated slide XML.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ShapeName,
+ [Parameter(Mandatory = $true)][string]$TextBodyXml
+ )
+
+ $escapedShapeName = [regex]::Escape($ShapeName)
+ $shapePattern = "(?s)(?:(?!).)*?]*name=`"$escapedShapeName`"(?:(?!).)*?"
+
+ return [regex]::Replace(
+ $SlideXml,
+ $shapePattern,
+ [System.Text.RegularExpressions.MatchEvaluator] {
+ param($match)
+ return [regex]::Replace($match.Value, "(?s).*?", $TextBodyXml, 1)
+ },
+ 1
+ )
+ }
+
+
+ function Set-PptxShapeTextByName {
+ <#
+ .SYNOPSIS
+ Sets text and basic formatting on a named PPTX shape.
+
+ .PARAMETER SlideXml
+ Slide XML containing the named shape.
+
+ .PARAMETER ShapeName
+ Shape name to find.
+
+ .PARAMETER Text
+ Replacement text.
+
+ .PARAMETER FontSize
+ OpenXML font size value.
+
+ .PARAMETER Bold
+ Indicates whether generated text should be bold.
+
+ .PARAMETER Alignment
+ Optional paragraph alignment value.
+
+ .OUTPUTS
+ Updated slide XML.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ShapeName,
+ [AllowNull()][string]$Text,
+ [string]$FontSize = "1800",
+ [bool]$Bold = $false,
+ [AllowNull()][string]$Alignment
+ )
+
+ $escapedShapeName = [regex]::Escape($ShapeName)
+ $shapePattern = "(?s)(?:(?!).)*?]*name=`"$escapedShapeName`"(?:(?!).)*?"
+
+ return [regex]::Replace(
+ $SlideXml,
+ $shapePattern,
+ [System.Text.RegularExpressions.MatchEvaluator] {
+ param($match)
+ $templateTextBodyMatch = [regex]::Match($match.Value, "(?s).*?")
+ $templateTextBodyXml = if ($templateTextBodyMatch.Success) { $templateTextBodyMatch.Value } else { $null }
+ $textBodyXml = New-PptxTextBodyXml -Text $Text -FontSize $FontSize -Bold $Bold -Alignment $Alignment -TemplateTextBodyXml $templateTextBodyXml
+ return [regex]::Replace($match.Value, "(?s).*?", $textBodyXml, 1)
+ },
+ 1
+ )
+ }
+
+
+ function Set-PptxShapeHyperlinkItemsByName {
+ <#
+ .SYNOPSIS
+ Sets hyperlink recommendation text on a named PPTX shape.
+
+ .PARAMETER SlideXml
+ Slide XML containing the named shape.
+
+ .PARAMETER ShapeName
+ Shape name to find.
+
+ .PARAMETER Items
+ Recommendation text objects with optional hyperlink relationship IDs.
+
+ .PARAMETER FontSize
+ OpenXML font size value.
+
+ .OUTPUTS
+ Updated slide XML.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ShapeName,
+ [Parameter(Mandatory = $true)][array]$Items,
+ [string]$FontSize = "1200"
+ )
+
+ $escapedShapeName = [regex]::Escape($ShapeName)
+ $shapePattern = "(?s)(?:(?!).)*?]*name=`"$escapedShapeName`"(?:(?!).)*?"
+
+ return [regex]::Replace(
+ $SlideXml,
+ $shapePattern,
+ [System.Text.RegularExpressions.MatchEvaluator] {
+ param($match)
+ $templateTextBodyMatch = [regex]::Match($match.Value, "(?s).*?")
+ $templateTextBodyXml = if ($templateTextBodyMatch.Success) { $templateTextBodyMatch.Value } else { $null }
+ $textBodyXml = New-PptxHyperlinkTextBodyXml -Items $Items -FontSize $FontSize -TemplateTextBodyXml $templateTextBodyXml
+ return [regex]::Replace($match.Value, "(?s).*?", $textBodyXml, 1)
+ },
+ 1
+ )
+ }
+
+
+ function Remove-PptxShapeByName {
+ <#
+ .SYNOPSIS
+ Removes a named text shape from slide XML.
+
+ .PARAMETER SlideXml
+ Slide XML containing the shape.
+
+ .PARAMETER ShapeName
+ Shape name to remove.
+
+ .OUTPUTS
+ Updated slide XML.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ShapeName
+ )
+
+ $escapedShapeName = [regex]::Escape($ShapeName)
+ $shapePattern = "(?s)(?:(?!).)*?]*name=`"$escapedShapeName`"(?:(?!).)*?"
+ return [regex]::Replace($SlideXml, $shapePattern, "", 1)
+ }
+
+
+ function Remove-PptxObjectByName {
+ <#
+ .SYNOPSIS
+ Removes a named shape, connector, picture, or graphic frame from slide XML.
+
+ .PARAMETER SlideXml
+ Slide XML containing the object.
+
+ .PARAMETER ObjectName
+ Object name to remove.
+
+ .OUTPUTS
+ Updated slide XML.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ObjectName
+ )
+
+ $escapedObjectName = [regex]::Escape($ObjectName)
+ $objectPattern = "(?s)).)*?]*name=`"$escapedObjectName`"(?:(?!).)*?"
+ return [regex]::Replace($SlideXml, $objectPattern, "", 1)
}
- #Add logic to get overall score
- $newSummarySlide = $summarySlide.Duplicate()
- $newSummarySlide.MoveTo($presentation.Slides.Count)
- $newSummarySlide.Shapes[3].TextFrame.TextRange.Text = $ScoreText
- $newSummarySlide.Shapes[4].TextFrame.TextRange.Text = $devOpsDescription
- [Double]$summBarScore = [int]$ScoreText * 2.47 + 56
- $newSummarySlide.Shapes[11].Left = $summBarScore
+ function Set-PptxPictureBlipByName {
+ <#
+ .SYNOPSIS
+ Changes the image relationship used by a named PPTX picture.
- $CategoriesList = New-Object System.Collections.ArrayList
- $categories = $data.Category | Sort-Object -Property "Weight" -Descending | Select-Object -Unique
-
-
- # Remove non existing (aka empty) categories. CASA has only 6 categories (no Advisor/uncategorized category)
- $FilteredCategoriesList = [System.Collections.ArrayList]($categories | Where-Object { $_ -ne "" })
- $categories = $FilteredCategoriesList
-
- foreach ($category in $categories) {
- $categoryWeight = ($data | Where-Object { $_.Category -eq $category }).Weight | Measure-Object -Sum
- $categoryScore = $categoryWeight.Sum / $categoryWeight.Count
- $categoryWeightiestCount = ($data | Where-Object { $_.Category -eq $category }).Weight -ge $MinimumReportLevel | Measure-Object
- $CategoriesList.Add([pscustomobject]@{"Category" = $category; "CategoryScore" = $categoryScore; "CategoryWeightiestCount" = $categoryWeightiestCount.Count }) | Out-Null
+ .PARAMETER SlideXml
+ Slide XML containing the picture.
+
+ .PARAMETER PictureName
+ Picture object name to update.
+
+ .PARAMETER RelationshipId
+ Relationship ID for the replacement image.
+
+ .OUTPUTS
+ Updated slide XML.
+ #>
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$PictureName,
+ [Parameter(Mandatory = $true)][string]$RelationshipId
+ )
+
+ $escapedPictureName = [regex]::Escape($PictureName)
+ $picturePattern = "(?s)).)*?]*name=`"$escapedPictureName`"(?:(?!).)*?"
+
+ return [regex]::Replace(
+ $SlideXml,
+ $picturePattern,
+ [System.Text.RegularExpressions.MatchEvaluator] {
+ param($match)
+ return [regex]::Replace($match.Value, '
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ShapeName,
+ [Parameter(Mandatory = $true)][double]$LeftPoints
+ )
+
+ $leftEmu = [int64][math]::Round($LeftPoints * 12700)
+ $escapedShapeName = [regex]::Escape($ShapeName)
+ $shapePattern = "(?s)).)*?]*name=`"$escapedShapeName`"(?:(?!).)*?"
+
+ return [regex]::Replace(
+ $SlideXml,
+ $shapePattern,
+ [System.Text.RegularExpressions.MatchEvaluator] {
+ param($match)
+ return [regex]::Replace($match.Value, '
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [Parameter(Mandatory = $true)][string]$ObjectName
+ )
+
+ $escapedObjectName = [regex]::Escape($ObjectName)
+ return [regex]::IsMatch($SlideXml, "]*name=`"$escapedObjectName`"")
}
- #Remove the boilerplate placeholder text if categories < 8
- if ($categories.Count -lt 8) {
- for ($k = $newSummarySlide.Shapes.count; $k -gt $counter - 1; $k--) {
- try {
- $newSummarySlide.Shapes[$k].Delete()
- $newSummarySlide.Shapes[$k + 1].Delete()
- }
- catch {}
+ function Add-MissingTemplateObjectIssue {
+ <#
+ .SYNOPSIS
+ Adds a template contract issue when a required named object is missing.
+
+ .PARAMETER Issues
+ Mutable list that receives issue text.
+
+ .PARAMETER SlideRole
+ Friendly slide role used in the issue text.
+
+ .PARAMETER SlideXml
+ Slide XML to inspect.
+
+ .PARAMETER ObjectName
+ Required object name.
+ #>
+ param(
+ [System.Collections.Generic.List[string]]$Issues,
+ [Parameter(Mandatory = $true)][string]$SlideRole,
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [AllowNull()][string]$ObjectName
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ObjectName)) {
+ return
+ }
+
+ if (-not (Test-PptxNamedObjectExists -SlideXml $SlideXml -ObjectName $ObjectName)) {
+ $Issues.Add("Missing $SlideRole template object '$ObjectName'")
}
}
- # Edit new category summary slide
- foreach ($category in $CategoriesList.Category) {
+ function ConvertTo-DoubleOrZero {
+ <#
+ .SYNOPSIS
+ Converts a value to a double using invariant culture, defaulting to zero.
+
+ .PARAMETER Value
+ Value to convert.
+
+ .OUTPUTS
+ Parsed double value, or 0 when parsing fails.
+ #>
+ param([AllowNull()][object]$Value)
- $categoryData = $data | Where-Object { $_.Category -eq $category }
- $categoryDataCount = ($categoryData | Measure-Object).Count
- $categoryWeight = ($data | Where-Object { $_.Category -eq $category }).Weight | Measure-Object -Sum
- $categoryScore = $categoryWeight.Sum / $categoryWeight.Count
- $categoryDescription = ($descriptionsFile | Where-Object { $categoryData.Category.Contains($_.Category) }).Description
- $y = $categoryDataCount
- $x = $ShowTop
- if ($categoryDataCount -lt $x) {
- $x = $categoryDataCount
+ $number = 0.0
+ if ([double]::TryParse(([string]$Value), [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$number)) {
+ return $number
}
+ return 0.0
+ }
- $newDetailSlide = $detailSlide.Duplicate()
- $newDetailSlide.MoveTo($presentation.Slides.Count)
+ try {
+ if (Test-Path $outputPath) {
+ Remove-Item -LiteralPath $outputPath -Force
+ }
+ if (Test-Path $tempRoot) {
+ Remove-Item -LiteralPath $tempRoot -Recurse -Force
+ }
- $newDetailSlide.Shapes[1].TextFrame.TextRange.Text = $category
- $newDetailSlide.Shapes[3].TextFrame.TextRange.Text = $categoryScore.ToString("#")
- [Double]$detailBarScore = $categoryScore * 2.48 + 38
- $newDetailSlide.Shapes[12].Left = $detailBarScore
- $newDetailSlide.Shapes[4].TextFrame.TextRange.Text = $categoryDescription
- $newDetailSlide.Shapes[7].TextFrame.TextRange.Text = "Top $x out of $y recommendations:"
- $sortedRecommendations = $categoryData | Sort-Object -Property "Link-Text" -Unique | Sort-Object -Property Weight -Descending | Select-Object -First $x
- $trimmedText = $sortedRecommendations | ForEach-Object { $_.'Link-Text'.Trim() }
- $newDetailSlide.Shapes[8].TextFrame.TextRange.Text = $trimmedText -join "`r`n`r`n"
+ [System.IO.Compression.ZipFile]::ExtractToDirectory($resolvedTemplatePresentation, $tempRoot)
+
+ $slidesDir = Join-Path $tempRoot "ppt/slides"
+ $slideRelsDir = Join-Path $slidesDir "_rels"
+ $presentationXmlPath = Join-Path $tempRoot "ppt/presentation.xml"
+ $presentationRelsPath = Join-Path $tempRoot "ppt/_rels/presentation.xml.rels"
+ $contentTypesPath = Join-Path $tempRoot "[Content_Types].xml"
+ $titleTemplateSlideName = "slide$($templateMap.TitleSlide.SourceSlide).xml"
+ $summaryTemplateSlideName = "slide$($templateMap.SummarySlide.SourceSlide).xml"
+ $detailTemplateSlideName = "slide$($templateMap.DetailSlide.SourceSlide).xml"
+ $endTemplateSlideName = "slide$($templateMap.EndSlide.SourceSlide).xml"
+ $titleTemplateSlidePath = Join-Path $slidesDir $titleTemplateSlideName
+ $titleTemplateSlideRelsPath = Join-Path $slideRelsDir "$titleTemplateSlideName.rels"
+ $summaryTemplateSlidePath = Join-Path $slidesDir $summaryTemplateSlideName
+ $summaryTemplateSlideRelsPath = Join-Path $slideRelsDir "$summaryTemplateSlideName.rels"
+ $detailTemplateSlidePath = Join-Path $slidesDir $detailTemplateSlideName
+ $detailTemplateSlideRelsPath = Join-Path $slideRelsDir "$detailTemplateSlideName.rels"
+ $endTemplateSlidePath = Join-Path $slidesDir $endTemplateSlideName
+ $endTemplateSlideRelsPath = Join-Path $slideRelsDir "$endTemplateSlideName.rels"
+
+ if (-not (Test-Path $titleTemplateSlidePath) -or -not (Test-Path $summaryTemplateSlidePath) -or -not (Test-Path $detailTemplateSlidePath) -or -not (Test-Path $endTemplateSlidePath)) {
+ Write-Error "Expected $AssessmentKind template slides $($templateMap.TitleSlide.SourceSlide), $($templateMap.SummarySlide.SourceSlide), $($templateMap.DetailSlide.SourceSlide), and $($templateMap.EndSlide.SourceSlide) were not found in template package."
+ return $false
+ }
- $lastFoundRange = $null
- foreach ($rec in $sortedRecommendations) {
- $recText = $rec.'Link-Text'.Trim()
- try {
- if ($null -eq $lastFoundRange) {
- $textRange = $newDetailSlide.Shapes[8].TextFrame.TextRange.Find($recText)
+ $titleTemplateSlideXml = Get-Content -LiteralPath $titleTemplateSlidePath -Raw
+ $summaryTemplateSlideXml = Get-Content -LiteralPath $summaryTemplateSlidePath -Raw
+ $detailTemplateSlideXml = Get-Content -LiteralPath $detailTemplateSlidePath -Raw
+ $templateContractIssues = [System.Collections.Generic.List[string]]::new()
+
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "title slide" -SlideXml $titleTemplateSlideXml -ObjectName $templateMap.TitleSlide.PillarName
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "title slide" -SlideXml $titleTemplateSlideXml -ObjectName $templateMap.TitleSlide.ReportDate
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $templateMap.SummarySlide.Score
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $templateMap.SummarySlide.Description
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $templateMap.SummarySlide.ScoreIndicator
+ foreach ($rowShape in $templateMap.SummarySlide.Rows) {
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $rowShape.Count
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $rowShape.Label
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $rowShape.Gauge
+ }
+ foreach ($templateGaugeName in $templateMap.SummarySlide.TemplateGauges) {
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "summary slide" -SlideXml $summaryTemplateSlideXml -ObjectName $templateGaugeName
+ }
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "detail slide" -SlideXml $detailTemplateSlideXml -ObjectName $templateMap.DetailSlide.Title
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "detail slide" -SlideXml $detailTemplateSlideXml -ObjectName $templateMap.DetailSlide.Score
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "detail slide" -SlideXml $detailTemplateSlideXml -ObjectName $templateMap.DetailSlide.Description
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "detail slide" -SlideXml $detailTemplateSlideXml -ObjectName $templateMap.DetailSlide.Header
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "detail slide" -SlideXml $detailTemplateSlideXml -ObjectName $templateMap.DetailSlide.Recommendations
+ Add-MissingTemplateObjectIssue -Issues $templateContractIssues -SlideRole "detail slide" -SlideXml $detailTemplateSlideXml -ObjectName $templateMap.DetailSlide.ScoreIndicator
+
+ if ($templateMap.SummarySlide.GaugeRelationships) {
+ if (-not (Test-Path -LiteralPath $summaryTemplateSlideRelsPath)) {
+ $templateContractIssues.Add("Missing summary slide relationships file for gauge images: $summaryTemplateSlideName.rels")
+ }
+ else {
+ $summaryTemplateRelsXml = Get-Content -LiteralPath $summaryTemplateSlideRelsPath -Raw
+ foreach ($relationshipId in $templateMap.SummarySlide.GaugeRelationships.Values) {
+ if ($summaryTemplateRelsXml -notmatch "Id=`"$([regex]::Escape($relationshipId))`"") {
+ $templateContractIssues.Add("Missing summary slide gauge relationship '$relationshipId'")
+ }
}
- else {
- $textRange = $newDetailSlide.Shapes[8].TextFrame.TextRange.Find($recText, $lastFoundRange.Start + $lastFoundRange.Length)
+ }
+ }
+
+ if ($templateContractIssues.Count -gt 0) {
+ Write-Error "Template contract validation failed for $AssessmentKind. $($templateContractIssues -join '; ')"
+ return $false
+ }
+
+ $groupedCategories = @()
+ if ($Data) {
+ $groupMap = @{}
+ foreach ($row in $Data) {
+ $assessmentCategory = [string]$row.Category
+ $reportingCategory = [string]$row.ReportingCategory
+
+ if ([string]::IsNullOrWhiteSpace($reportingCategory)) {
+ $reportingCategory = $assessmentCategory
}
- if ($textRange) {
- $textRange.ActionSettings(1).HyperLink.Address = $rec.Link
- $lastFoundRange = $textRange
+ if ([string]::IsNullOrWhiteSpace($assessmentCategory) -and [string]::IsNullOrWhiteSpace($reportingCategory)) {
+ continue
}
- else {
- Write-Warning "Could not find text in shape: $recText"
+
+ $groupKey = "$assessmentCategory|$reportingCategory"
+ if (-not $groupMap.ContainsKey($groupKey)) {
+ $groupMap[$groupKey] = [pscustomobject]@{
+ AssessmentCategory = $assessmentCategory
+ ReportingCategory = $reportingCategory
+ Name = $(if ([string]::IsNullOrWhiteSpace($assessmentCategory)) { $reportingCategory } else { "$assessmentCategory - $reportingCategory" })
+ Group = New-Object System.Collections.ArrayList
+ }
}
+
+ [void]$groupMap[$groupKey].Group.Add($row)
}
- catch {
- Write-Warning "Failed to set hyperlink for: $recText - Error: $_"
- }
+
+ $groupedCategories = $groupMap.Values |
+ Sort-Object @{ Expression = { $_.Group.Count }; Descending = $true }, AssessmentCategory, ReportingCategory
+ }
+
+ if ($groupedCategories.Count -eq 0) {
+ Write-Error "No categories found for OpenXML PPTX generation."
+ return $false
}
- }
-}
-Function GenAIAssessment
-{
- $slideTitle = $title
- $newTitleSlide = $titleSlide.Duplicate()
- $newTitleSlide.MoveTo($presentation.Slides.Count)
- $newTitleSlide.Shapes("Title").TextFrame.TextRange.Text = $slideTitle
- $newTitleSlide.Shapes("ReportDate").TextFrame.TextRange.Text = $newTitleSlide.Shapes("ReportDate").TextFrame.TextRange.Text.Replace("[Report_Date]", $localReportDate)
-
- if (![string]::IsNullOrEmpty($overallScore)) {
- $ScoreText = "$($overallScore)"
- }
-
- $newSummarySlide = $summarySlide.Duplicate()
- $newSummarySlide.MoveTo($presentation.Slides.Count)
- $newSummarySlide.Shapes("ScoreText").TextFrame.TextRange.Text = $ScoreText
- $newSummarySlide.Shapes("PillarDescription").TextFrame.TextRange.Text = $genAIDescription
- [Double]$summBarScore = [int]$ScoreText * 2.47 + 56
- $newSummarySlide.Shapes("ScoreIndicator").Left = $summBarScore
-
- $categoryField = "ReportingCategory"
- if (-not ($data | Get-Member -Name $categoryField -ErrorAction SilentlyContinue)) {
- $categoryField = "Category"
- }
-
- $prefixMap = @{}
- foreach ($d in $descriptionsFile) {
- if ([string]::IsNullOrWhiteSpace($d.Category)) { continue }
- $pfx = ($d.Category -split ':')[0].Trim()
- if ([string]::IsNullOrWhiteSpace($pfx)) { continue }
-
- if (-not $prefixMap.ContainsKey($pfx)) {
- $prefixMap[$pfx] = [pscustomobject]@{
- Prefix = $pfx
- Pillar = $d.Pillar
- Caption = $(if (-not [string]::IsNullOrWhiteSpace($d.Caption)) { $d.Caption } elseif (-not [string]::IsNullOrWhiteSpace($d.Pillar)) { $d.Pillar } else { $pfx })
- Description = $d.Description
+ $existingSlideNumbers = Get-ChildItem -Path $slidesDir -Filter "slide*.xml" |
+ ForEach-Object { if ($_.BaseName -match '^slide(\d+)$') { [int]$matches[1] } }
+ $nextSlideNumber = (($existingSlideNumbers | Measure-Object -Maximum).Maximum) + 1
+
+ $presentationXml = Get-Content -LiteralPath $presentationXmlPath -Raw
+ $presentationRels = Get-Content -LiteralPath $presentationRelsPath -Raw
+ $contentTypesXml = Get-Content -LiteralPath $contentTypesPath -Raw
+
+ foreach ($templateSlideNumber in @($templateMap.TitleSlide.SourceSlide, $templateMap.SummarySlide.SourceSlide, $templateMap.DetailSlide.SourceSlide, $templateMap.EndSlide.SourceSlide)) {
+ $templateTarget = "slides/slide$templateSlideNumber.xml"
+ $targetPattern = [regex]::Escape($templateTarget)
+ $templateRelMatch = [regex]::Match($presentationRels, "]*Target=`"$targetPattern`"[^>]*/>")
+ if ($templateRelMatch.Success) {
+ $templateRelId = [regex]::Escape($templateRelMatch.Groups[1].Value)
+ $presentationXml = [regex]::Replace($presentationXml, "\s*]*r:id=`"$templateRelId`"\s*/>", "", 1)
}
}
- else {
- if ([string]::IsNullOrWhiteSpace($prefixMap[$pfx].Pillar) -and -not [string]::IsNullOrWhiteSpace($d.Pillar)) { $prefixMap[$pfx].Pillar = $d.Pillar }
- if ([string]::IsNullOrWhiteSpace($prefixMap[$pfx].Caption) -and -not [string]::IsNullOrWhiteSpace($d.Caption)) { $prefixMap[$pfx].Caption = $d.Caption }
- if ([string]::IsNullOrWhiteSpace($prefixMap[$pfx].Description) -and -not [string]::IsNullOrWhiteSpace($d.Description)) { $prefixMap[$pfx].Description = $d.Description }
+
+ $nextSlideId = 256
+ $slideIdMatches = [regex]::Matches($presentationXml, '
+ param(
+ [Parameter(Mandatory = $true)][string]$SlideXml,
+ [AllowNull()][string]$SourceRelsPath,
+ [array]$ExternalHyperlinks = @()
+ )
+
+ $newSlideName = "slide$nextSlideNumber.xml"
+ $newSlidePath = Join-Path $slidesDir $newSlideName
+ $newSlideRelsPath = Join-Path $slideRelsDir "$newSlideName.rels"
+
+ $SlideXml = [regex]::Replace($SlideXml, "(?s).*?", "")
+ Set-Content -LiteralPath $newSlidePath -Value $SlideXml -NoNewline
+ if (-not [string]::IsNullOrWhiteSpace($SourceRelsPath) -and (Test-Path $SourceRelsPath)) {
+ Copy-Item -LiteralPath $SourceRelsPath -Destination $newSlideRelsPath -Force
+ $newSlideRelsXml = Get-Content -LiteralPath $newSlideRelsPath -Raw
+ $newSlideRelsXml = [regex]::Replace($newSlideRelsXml, ']*Type="http://schemas\.openxmlformats\.org/officeDocument/2006/relationships/tags"[^>]*/>', '')
+ $newSlideRelsXml = [regex]::Replace($newSlideRelsXml, ']*Type="http://schemas\.openxmlformats\.org/officeDocument/2006/relationships/notesSlide"[^>]*/>', '')
+ Set-Content -LiteralPath $newSlideRelsPath -Value $newSlideRelsXml -NoNewline
+ }
+ if ($ExternalHyperlinks.Count -gt 0) {
+ if (-not (Test-Path $newSlideRelsPath)) {
+ Set-Content -LiteralPath $newSlideRelsPath -Value '' -NoNewline
+ }
- $CategoriesList = New-Object System.Collections.ArrayList
- $categories = ($data | Select-Object -ExpandProperty $categoryField) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique # 12 categories
+ $slideRelsXml = Get-Content -LiteralPath $newSlideRelsPath -Raw
+ foreach ($hyperlink in $ExternalHyperlinks) {
+ if ([string]::IsNullOrWhiteSpace($hyperlink.Id) -or [string]::IsNullOrWhiteSpace($hyperlink.Target)) {
+ continue
+ }
+ $hyperlinkXml = ""
+ $slideRelsXml = $slideRelsXml -replace '', "$hyperlinkXml"
+ }
+ Set-Content -LiteralPath $newSlideRelsPath -Value $slideRelsXml -NoNewline
+ }
-
- foreach ($category in $categories) {
- $categoryData = $data | Where-Object { $_.$categoryField -eq $category }
- $weights = $categoryData.Weight | Measure-Object -Average
- $categoryScore = if ($weights.Count -gt 0) { [double]$weights.Average } else { 0 }
- $categoryWeightiestCount = ($categoryData | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $newRelId = "rId$nextRelId"
+ $updatedPresentationXml = $presentationXml -replace '', ""
+ $relationshipXml = ""
+ $updatedPresentationRels = $presentationRels -replace '', "$relationshipXml"
+ $overrideXml = ""
+ $updatedContentTypesXml = $contentTypesXml -replace '', "$overrideXml"
+
+ Set-Variable -Name presentationXml -Value $updatedPresentationXml -Scope 1
+ Set-Variable -Name presentationRels -Value $updatedPresentationRels -Scope 1
+ Set-Variable -Name contentTypesXml -Value $updatedContentTypesXml -Scope 1
+ Set-Variable -Name nextSlideNumber -Value ($nextSlideNumber + 1) -Scope 1
+ Set-Variable -Name nextSlideId -Value ($nextSlideId + 1) -Scope 1
+ Set-Variable -Name nextRelId -Value ($nextRelId + 1) -Scope 1
+ }
+ if ($AssessmentKind -eq "CASA") {
+ $categoryGroups = @($Data |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_.ReportingCategory) } |
+ Group-Object -Property ReportingCategory |
+ Sort-Object -Property Count -Descending)
- $categoryLabel = $category
- $matchingDesc = $descriptionsFile | Where-Object { $_.Category -eq $category }
- if ($matchingDesc -and -not [string]::IsNullOrWhiteSpace($matchingDesc.Caption)) {
- $categoryLabel = $matchingDesc.Caption
- }
+ if ($categoryGroups.Count -eq 0) {
+ Write-Error "No reporting categories found for CASA OpenXML PPTX generation."
+ return $false
+ }
- $CategoriesList.Add([pscustomobject]@{
- "Prefix" = $category
- "Category" = $categoryLabel
- "CategoryScore" = $categoryScore
- "CategoryWeightiestCount" = $categoryWeightiestCount
- }) | Out-Null
- }
+ $summaryDescriptionText = if (-not [string]::IsNullOrWhiteSpace($SummaryDescription)) { $SummaryDescription } else { "$AssessmentTitle assessment summary." }
+ $titleSlideXml = Get-Content -LiteralPath $titleTemplateSlidePath -Raw
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.PillarName -Text $AssessmentTitle -FontSize "3600" -Bold $true
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.ReportDate -Text "Report generated: $LocalReportDate" -FontSize "1200"
+ Add-ManualSlidePart -SlideXml $titleSlideXml -SourceRelsPath $titleTemplateSlideRelsPath
+
+ $summaryRows = @()
+ foreach ($categoryGroup in $categoryGroups) {
+ $categoryWeights = $categoryGroup.Group | Select-Object -ExpandProperty Weight
+ $categoryScore = if ($categoryWeights.Count -gt 0) { (($categoryWeights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryGroup.Group | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $summaryRows += [pscustomobject]@{
+ Caption = $categoryGroup.Name
+ Score = $categoryScore
+ HighCount = $highCount
+ }
+ }
- #$CategoriesList = $CategoriesList | Sort-Object -Property CategoryScore -Descending
- #$CategoriesList = $CategoriesList | Sort-Object -Property Prefix
- # Define category sort order: SA first, then RC, then AG
- $categoryOrder = @{
- 'SA' = 1
- 'RC' = 2
- 'AG' = 3
- }
+ for ($summaryStart = 0; $summaryStart -lt $summaryRows.Count; $summaryStart += $templateMap.SummarySlide.Rows.Count) {
+ $summaryPageRows = @($summaryRows | Select-Object -Skip $summaryStart -First $templateMap.SummarySlide.Rows.Count)
+ $summarySlideXml = Get-Content -LiteralPath $summaryTemplateSlidePath -Raw
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Score -Text $OverallScore -FontSize "2400" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Description -Text $summaryDescriptionText -FontSize "1100" -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeOffsetXByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.ScoreIndicator -LeftPoints ((ConvertTo-DoubleOrZero $OverallScore) * 2.47 + 56)
+
+ for ($rowIndex = 1; $rowIndex -le $templateMap.SummarySlide.Rows.Count; $rowIndex++) {
+ $rowShapes = $templateMap.SummarySlide.Rows[$rowIndex - 1]
+ $row = if ($rowIndex -le $summaryPageRows.Count) { $summaryPageRows[$rowIndex - 1] } else { $null }
+ if ($null -ne $row) {
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count -Text ([string]$row.HighCount) -FontSize "1200" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label -Text ([string]$row.Caption) -FontSize "1200"
+ }
+ else {
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $rowShapes.Gauge
+ }
+ }
+ foreach ($templateGaugeName in $templateMap.SummarySlide.TemplateGauges) {
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $templateGaugeName
+ }
+ Add-ManualSlidePart -SlideXml $summarySlideXml -SourceRelsPath $summaryTemplateSlideRelsPath
+ }
+
+ foreach ($categoryGroup in $categoryGroups) {
+ $categoryName = $categoryGroup.Name
+ $categoryRows = $categoryGroup.Group
+ $weights = $categoryRows | Select-Object -ExpandProperty Weight
+ $averageScore = if ($weights.Count -gt 0) { (($weights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryRows | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $categoryTop = $categoryRows |
+ Sort-Object -Property Weight -Descending |
+ Select-Object -Unique -Property "Link-Text", Link, Weight |
+ Select-Object -First $ShowTop
+
+ $recommendationItems = @()
+ $hyperlinkRelationships = @()
+ $detailTemplateRelIds = if (Test-Path $detailTemplateSlideRelsPath) { [regex]::Matches((Get-Content -LiteralPath $detailTemplateSlideRelsPath -Raw), 'Id="rId(\d+)"') } else { @() }
+ $nextHyperlinkRelId = 1
+ if ($detailTemplateRelIds.Count -gt 0) {
+ $nextHyperlinkRelId = (($detailTemplateRelIds | ForEach-Object { [int]$_.Groups[1].Value } | Measure-Object -Maximum).Maximum) + 1
+ }
+
+ foreach ($rec in $categoryTop) {
+ $recText = ($rec.'Link-Text' | Out-String).Trim()
+ if ([string]::IsNullOrWhiteSpace($recText)) {
+ $recText = "(no recommendation title provided)"
+ }
+
+ $relationshipId = $null
+ $recommendationLink = Resolve-SafeHyperlinkTarget -Target ([string]$rec.Link)
+ if (-not [string]::IsNullOrWhiteSpace($recommendationLink)) {
+ $relationshipId = "rId$nextHyperlinkRelId"
+ $nextHyperlinkRelId++
+ $hyperlinkRelationships += [pscustomobject]@{
+ Id = $relationshipId
+ Target = $recommendationLink
+ }
+ }
+
+ $recommendationItems += [pscustomobject]@{
+ Text = $recText
+ RelationshipId = $relationshipId
+ }
+ }
+
+ if ($recommendationItems.Count -eq 0) {
+ $recommendationItems += [pscustomobject]@{
+ Text = "No recommendations in this category."
+ RelationshipId = $null
+ }
+ }
+
+ $mappedDescription = ($descriptionsFile | Where-Object { $categoryName.Contains($_.Category) } | Select-Object -First 1).Description
+ $categoryDescription = if (-not [string]::IsNullOrWhiteSpace($mappedDescription)) { [string]$mappedDescription } else {
+ @(
+ "Average Score: $([math]::Round($averageScore, 0))"
+ "Recommendations In Category: $($categoryRows.Count)"
+ "Recommendations >= ${MinimumReportLevel}: $highCount"
+ ) -join "`n"
+ }
+ $recommendationHeader = "Top $($categoryTop.Count) out of $($categoryRows.Count) recommendations:"
+
+ $slideXml = Get-Content -LiteralPath $detailTemplateSlidePath -Raw
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Title -Text $categoryName -FontSize "2400" -Bold $true
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Score -Text ([math]::Round($averageScore, 0).ToString()) -FontSize "2400" -Bold $true -Alignment "ctr"
+ $slideXml = Set-PptxShapeOffsetXByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.ScoreIndicator -LeftPoints ($averageScore * 2.48 + 38)
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Description -Text $categoryDescription -FontSize "1000" -Alignment "just"
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Header -Text $recommendationHeader -FontSize "1600" -Bold $true
+ $slideXml = Set-PptxShapeHyperlinkItemsByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Recommendations -Items $recommendationItems -FontSize "2000"
+ Add-ManualSlidePart -SlideXml $slideXml -SourceRelsPath $detailTemplateSlideRelsPath -ExternalHyperlinks $hyperlinkRelationships
+ }
+ }
+ elseif ($AssessmentKind -eq "DevOps") {
+ $categoryGroups = @($Data |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_.Category) -and $_.Category -ne "Uncategorized" } |
+ Group-Object -Property Category)
+
+ if ($categoryGroups.Count -eq 0) {
+ Write-Error "No categories found for DevOps OpenXML PPTX generation."
+ return $false
+ }
+
+ $summaryDescriptionText = if (-not [string]::IsNullOrWhiteSpace($SummaryDescription)) { $SummaryDescription } else { "$AssessmentTitle assessment summary." }
+ $titleSlideXml = Get-Content -LiteralPath $titleTemplateSlidePath -Raw
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.PillarName -Text $AssessmentTitle -FontSize "3600" -Bold $true
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.ReportDate -Text "Report generated: $LocalReportDate" -FontSize "1200"
+ Add-ManualSlidePart -SlideXml $titleSlideXml -SourceRelsPath $titleTemplateSlideRelsPath
+
+ $summaryRows = @()
+ foreach ($categoryGroup in $categoryGroups) {
+ $categoryWeights = $categoryGroup.Group | Select-Object -ExpandProperty Weight
+ $categoryScore = if ($categoryWeights.Count -gt 0) { (($categoryWeights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryGroup.Group | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $summaryRows += [pscustomobject]@{
+ Caption = $categoryGroup.Name
+ Score = $categoryScore
+ HighCount = $highCount
+ Group = $categoryGroup.Group
+ }
+ }
+
+ $summaryRows = @($summaryRows | Sort-Object -Property @{ Expression = { $_.Score }; Descending = $true })
+ for ($summaryStart = 0; $summaryStart -lt $summaryRows.Count; $summaryStart += $templateMap.SummarySlide.Rows.Count) {
+ $summaryPageRows = @($summaryRows | Select-Object -Skip $summaryStart -First $templateMap.SummarySlide.Rows.Count)
+ $summarySlideXml = Get-Content -LiteralPath $summaryTemplateSlidePath -Raw
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Score -Text $OverallScore -FontSize "2400" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Description -Text $summaryDescriptionText -FontSize "1100" -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeOffsetXByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.ScoreIndicator -LeftPoints ((ConvertTo-DoubleOrZero $OverallScore) * 2.47 + 56)
+
+ for ($rowIndex = 1; $rowIndex -le $templateMap.SummarySlide.Rows.Count; $rowIndex++) {
+ $rowShapes = $templateMap.SummarySlide.Rows[$rowIndex - 1]
+ $row = if ($rowIndex -le $summaryPageRows.Count) { $summaryPageRows[$rowIndex - 1] } else { $null }
+ if ($null -ne $row) {
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count -Text ([string]$row.HighCount) -FontSize "1200" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label -Text ([string]$row.Caption) -FontSize "1200"
+ $gaugeRelationshipId = if ($row.Score -lt 33) { $templateMap.SummarySlide.GaugeRelationships.Green } elseif ($row.Score -lt 67) { $templateMap.SummarySlide.GaugeRelationships.Yellow } else { $templateMap.SummarySlide.GaugeRelationships.Red }
+ $summarySlideXml = Set-PptxPictureBlipByName -SlideXml $summarySlideXml -PictureName $rowShapes.Gauge -RelationshipId $gaugeRelationshipId
+ }
+ else {
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $rowShapes.Gauge
+ }
+ }
+ foreach ($templateGaugeName in $templateMap.SummarySlide.TemplateGauges) {
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $templateGaugeName
+ }
+ Add-ManualSlidePart -SlideXml $summarySlideXml -SourceRelsPath $summaryTemplateSlideRelsPath
+ }
+
+ foreach ($categoryRow in $summaryRows) {
+ $categoryRows = $categoryRow.Group
+ $weights = $categoryRows | Select-Object -ExpandProperty Weight
+ $averageScore = if ($weights.Count -gt 0) { (($weights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryRows | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $categoryTop = $categoryRows |
+ Sort-Object -Property Weight -Descending |
+ Select-Object -Unique -Property "Link-Text", Link, Weight |
+ Select-Object -First $ShowTop
+
+ $recommendationItems = @()
+ $hyperlinkRelationships = @()
+ $detailTemplateRelIds = if (Test-Path $detailTemplateSlideRelsPath) { [regex]::Matches((Get-Content -LiteralPath $detailTemplateSlideRelsPath -Raw), 'Id="rId(\d+)"') } else { @() }
+ $nextHyperlinkRelId = 1
+ if ($detailTemplateRelIds.Count -gt 0) {
+ $nextHyperlinkRelId = (($detailTemplateRelIds | ForEach-Object { [int]$_.Groups[1].Value } | Measure-Object -Maximum).Maximum) + 1
+ }
+
+ foreach ($rec in $categoryTop) {
+ $recText = ($rec.'Link-Text' | Out-String).Trim()
+ if ([string]::IsNullOrWhiteSpace($recText)) {
+ $recText = "(no recommendation title provided)"
+ }
+
+ $relationshipId = $null
+ $recommendationLink = Resolve-SafeHyperlinkTarget -Target ([string]$rec.Link)
+ if (-not [string]::IsNullOrWhiteSpace($recommendationLink)) {
+ $relationshipId = "rId$nextHyperlinkRelId"
+ $nextHyperlinkRelId++
+ $hyperlinkRelationships += [pscustomobject]@{
+ Id = $relationshipId
+ Target = $recommendationLink
+ }
+ }
+
+ $recommendationItems += [pscustomobject]@{
+ Text = $recText
+ RelationshipId = $relationshipId
+ }
+ }
+
+ if ($recommendationItems.Count -eq 0) {
+ $recommendationItems += [pscustomobject]@{
+ Text = "No recommendations in this category."
+ RelationshipId = $null
+ }
+ }
- #Sort by category prefix first (SA, RC, AG), but within each category prefix, sort by the CategoryScore descending.
- $CategoriesList = $CategoriesList | Sort-Object @{Expression={
- $prefix = ($_.Prefix -split ':')[0]
- if ($categoryOrder.ContainsKey($prefix)) { $categoryOrder[$prefix] } else { 99 }
- }}, @{Expression={$_.CategoryScore}; Descending=$true}
+ $mappedDescription = ($descriptionsFile | Where-Object { $categoryRow.Caption.Contains($_.Category) } | Select-Object -First 1).Description
+ $categoryDescription = if (-not [string]::IsNullOrWhiteSpace($mappedDescription)) { [string]$mappedDescription } else {
+ @(
+ "Average Score: $([math]::Round($averageScore, 0))"
+ "Recommendations In Category: $($categoryRows.Count)"
+ "Recommendations >= ${MinimumReportLevel}: $highCount"
+ ) -join "`n"
+ }
+ $recommendationHeader = "Top $($categoryTop.Count) out of $($categoryRows.Count) recommendations:"
+
+ $slideXml = Get-Content -LiteralPath $detailTemplateSlidePath -Raw
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Title -Text $categoryRow.Caption -FontSize "2400" -Bold $true
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Score -Text ([math]::Round($averageScore, 0).ToString()) -FontSize "2400" -Bold $true -Alignment "ctr"
+ $slideXml = Set-PptxShapeOffsetXByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.ScoreIndicator -LeftPoints ($averageScore * 2.48 + 38)
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Description -Text $categoryDescription -FontSize "1000" -Alignment "just"
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Header -Text $recommendationHeader -FontSize "1600" -Bold $true
+ $slideXml = Set-PptxShapeHyperlinkItemsByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Recommendations -Items $recommendationItems -FontSize "2000"
+ Add-ManualSlidePart -SlideXml $slideXml -SourceRelsPath $detailTemplateSlideRelsPath -ExternalHyperlinks $hyperlinkRelationships
+ }
+ }
+ elseif ($AssessmentKind -eq "GenAI") {
+ $categoryField = "ReportingCategory"
+ $firstDataRow = @($Data | Select-Object -First 1)[0]
+ if ($null -eq $firstDataRow -or -not ($firstDataRow.PSObject.Properties.Name -contains $categoryField)) {
+ $categoryField = "Category"
+ }
+ $categoryGroups = @($Data |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_.$categoryField) } |
+ Group-Object -Property $categoryField)
- $redTemplate = $null
- $yellowTemplate = $null
- $greenTemplate = $null
-
- for ($i = 1; $i -le $newSummarySlide.Shapes.Count; $i++) {
- try {
- $sh = $newSummarySlide.Shapes[$i]
- if ($null -eq $sh) { continue }
-
- $shapeName = $sh.Name
- if ([string]::IsNullOrWhiteSpace($shapeName)) { continue }
-
- if ($shapeName -match "Gauge.*Red" -or $shapeName -eq "Gauge_Red") {
- $redTemplate = $sh
+ if ($categoryGroups.Count -eq 0) {
+ Write-Error "No reporting categories found for GenAI OpenXML PPTX generation."
+ return $false
}
- elseif ($shapeName -match "Gauge.*Yellow" -or $shapeName -eq "Gauge_Yellow") {
- $yellowTemplate = $sh
- }
- elseif ($shapeName -match "Gauge.*Green" -or $shapeName -eq "Gauge_Green") {
- $greenTemplate = $sh
+
+ $categoryOrder = @{
+ "SA" = 1
+ "RC" = 2
+ "AG" = 3
}
- } catch {
- continue
- }
- }
-
- if (-not $redTemplate -or -not $yellowTemplate -or -not $greenTemplate) {
- Write-Error "Could not find all gauge templates by name."
- return
- }
- $maxRows = 12
- $rowInfo = @()
-
- for ($rowNum = 1; $rowNum -le $maxRows; $rowNum++) {
- $placeholderGauge = $null
- $countBox = $null
- $nameBox = $null
- $gaugePosition = $null
-
- for ($i = 1; $i -le $newSummarySlide.Shapes.Count; $i++) {
- try {
- $sh = $newSummarySlide.Shapes[$i]
- if ($null -eq $sh) { continue }
-
- $shapeName = $sh.Name
- if ([string]::IsNullOrWhiteSpace($shapeName)) { continue }
-
- if ($shapeName -match "Gauge[_\s]*(Row[_\s]*)?$rowNum$" -or $shapeName -eq "Gauge_$rowNum") {
- $placeholderGauge = $sh
- $gaugePosition = @{Left = $sh.Left; Top = $sh.Top}
- }
- elseif ($shapeName -match "Count[_\s]*(Row[_\s]*)?$rowNum$" -or $shapeName -eq "Count_$rowNum" -or $shapeName -eq "S$rowNum") {
- $countBox = $sh
- }
- elseif ($shapeName -match "Name[_\s]*(Row[_\s]*)?$rowNum$" -or $shapeName -eq "Name_$rowNum" -or $shapeName -eq "Domain_$rowNum") {
- $nameBox = $sh
+ $summaryRows = @()
+ foreach ($categoryGroup in $categoryGroups) {
+ $categoryName = [string]$categoryGroup.Name
+ $prefix = ($categoryName -split ':')[0].Trim()
+ $categoryWeights = $categoryGroup.Group | Select-Object -ExpandProperty Weight
+ $categoryScore = if ($categoryWeights.Count -gt 0) { (($categoryWeights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryGroup.Group | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $matchingDescription = $descriptionsFile | Where-Object { $_.Category -eq $categoryName } | Select-Object -First 1
+ $caption = if ($matchingDescription -and -not [string]::IsNullOrWhiteSpace($matchingDescription.Caption)) { [string]$matchingDescription.Caption } else { $categoryName }
+ $sortOrder = if ($categoryOrder.ContainsKey($prefix)) { $categoryOrder[$prefix] } else { 99 }
+
+ $summaryRows += [pscustomobject]@{
+ CategoryName = $categoryName
+ Caption = $caption
+ Prefix = $prefix
+ SortOrder = $sortOrder
+ Score = $categoryScore
+ HighCount = $highCount
+ Group = $categoryGroup.Group
+ Description = if ($matchingDescription) { [string]$matchingDescription.Description } else { "" }
}
- } catch {
- continue
- }
- }
-
- if ($countBox -and $nameBox) {
- $rowInfo += @{
- RowNum = $rowNum
- PlaceholderGauge = $placeholderGauge
- CountBox = $countBox
- NameBox = $nameBox
- GaugePosition = $gaugePosition
}
- }
- }
-
- if ($rowInfo.Count -eq 0) {
- Write-Error "Could not find any named rows."
- return
- }
- $categoryCounter = 0
-
- foreach ($category in $CategoriesList) {
- if ($category.Category -ne "Uncategorized" -and $categoryCounter -lt $rowInfo.Count) {
- try {
- $row = $rowInfo[$categoryCounter]
-
- #$row.CountBox.TextFrame.TextRange.Text = $category.CategoryWeightiestCount.ToString("#")
- $row.CountBox.TextFrame.TextRange.Text = $category.CategoryWeightiestCount.ToString()
- $row.NameBox.TextFrame.TextRange.Text = $category.Category
-
- $scoreValue = $category.CategoryScore
- if ($scoreValue -le 33) {
- $categoryShape = $greenTemplate
+ $summaryRows = @($summaryRows | Sort-Object -Property SortOrder, @{ Expression = { $_.Score }; Descending = $true })
+ $summaryDescriptionText = if (-not [string]::IsNullOrWhiteSpace($SummaryDescription)) { $SummaryDescription } else { "$AssessmentTitle assessment summary." }
+
+ $titleSlideXml = Get-Content -LiteralPath $titleTemplateSlidePath -Raw
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.PillarName -Text $AssessmentTitle -FontSize "3600" -Bold $true
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.ReportDate -Text "Report generated: $LocalReportDate" -FontSize "1200"
+ Add-ManualSlidePart -SlideXml $titleSlideXml -SourceRelsPath $titleTemplateSlideRelsPath
+
+ for ($summaryStart = 0; $summaryStart -lt $summaryRows.Count; $summaryStart += $templateMap.SummarySlide.Rows.Count) {
+ $summaryPageRows = @($summaryRows | Select-Object -Skip $summaryStart -First $templateMap.SummarySlide.Rows.Count)
+ $summarySlideXml = Get-Content -LiteralPath $summaryTemplateSlidePath -Raw
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Score -Text $OverallScore -FontSize "2400" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Description -Text $summaryDescriptionText -FontSize "1100" -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeOffsetXByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.ScoreIndicator -LeftPoints ((ConvertTo-DoubleOrZero $OverallScore) * 2.47 + 56)
+
+ for ($rowIndex = 1; $rowIndex -le $templateMap.SummarySlide.Rows.Count; $rowIndex++) {
+ $rowShapes = $templateMap.SummarySlide.Rows[$rowIndex - 1]
+ $row = if ($rowIndex -le $summaryPageRows.Count) { $summaryPageRows[$rowIndex - 1] } else { $null }
+ if ($null -ne $row) {
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count -Text ([string]$row.HighCount) -FontSize "1200" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label -Text ([string]$row.Caption) -FontSize "1200"
+ $gaugeRelationshipId = if ($row.Score -le 33) { $templateMap.SummarySlide.GaugeRelationships.Green } elseif ($row.Score -lt 67) { $templateMap.SummarySlide.GaugeRelationships.Yellow } else { $templateMap.SummarySlide.GaugeRelationships.Red }
+ $summarySlideXml = Set-PptxPictureBlipByName -SlideXml $summarySlideXml -PictureName $rowShapes.Gauge -RelationshipId $gaugeRelationshipId
+ }
+ else {
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $rowShapes.Gauge
+ }
}
- elseif ($scoreValue -lt 67) {
- $categoryShape = $yellowTemplate
+ foreach ($templateGaugeName in $templateMap.SummarySlide.TemplateGauges) {
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $templateGaugeName
}
- else {
- $categoryShape = $redTemplate
+ Add-ManualSlidePart -SlideXml $summarySlideXml -SourceRelsPath $summaryTemplateSlideRelsPath
+ }
+
+ foreach ($categoryRow in $summaryRows) {
+ $categoryRows = $categoryRow.Group
+ $weights = $categoryRows | Select-Object -ExpandProperty Weight
+ $averageScore = if ($weights.Count -gt 0) { (($weights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryRows | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $categoryTop = $categoryRows |
+ Sort-Object -Property Weight -Descending |
+ Select-Object -First $ShowTop
+
+ $recommendationItems = @()
+ $hyperlinkRelationships = @()
+ $detailTemplateRelIds = if (Test-Path $detailTemplateSlideRelsPath) { [regex]::Matches((Get-Content -LiteralPath $detailTemplateSlideRelsPath -Raw), 'Id="rId(\d+)"') } else { @() }
+ $nextHyperlinkRelId = 1
+ if ($detailTemplateRelIds.Count -gt 0) {
+ $nextHyperlinkRelId = (($detailTemplateRelIds | ForEach-Object { [int]$_.Groups[1].Value } | Measure-Object -Maximum).Maximum) + 1
}
- $gaugeLeft = 437.76
- $gaugeTop = 147.6 + ($categoryCounter * 28.8)
-
- if ($row.PlaceholderGauge -ne $null) {
- try {
- $gaugeLeft = [double]([float]$row.PlaceholderGauge.Left)
- $gaugeTop = [double]([float]$row.PlaceholderGauge.Top)
- $row.PlaceholderGauge.Delete()
- $row.PlaceholderGauge = $null
- } catch {
- # Silently continue if placeholder can't be deleted
+ foreach ($rec in $categoryTop) {
+ $recText = ($rec.'Link-Text' | Out-String).Trim()
+ if ([string]::IsNullOrWhiteSpace($recText)) {
+ $recText = "(no recommendation title provided)"
}
- } elseif ($row.GaugePosition -ne $null) {
- $gaugeLeft = [double]([float]$row.GaugePosition.Left)
- $gaugeTop = [double]([float]$row.GaugePosition.Top)
- }
- $categoryShape.Duplicate() | Out-Null
- $newShape = $newSummarySlide.Shapes.Count
- $newSummarySlide.Shapes[$newShape].Left = [double]$gaugeLeft
- $newSummarySlide.Shapes[$newShape].Top = [double]$gaugeTop
- $newSummarySlide.Shapes[$newShape].Name = "NewGauges"
-
- $categoryCounter++
- }
- catch {
- Write-Warning "Error processing category '$($category.Category)': $($_.Exception.Message)"
- }
- }
- }
-
- for ($i = $rowInfo.Count - 1; $i -ge $categoryCounter; $i--) {
- try {
- $row = $rowInfo[$i]
-
- if ($row.PlaceholderGauge) {
- try { $row.PlaceholderGauge.Delete() } catch {}
- }
-
- try { $row.NameBox.Delete() } catch {}
- try { $row.CountBox.Delete() } catch {}
- } catch {
- continue
- }
- }
+ $relationshipId = $null
+ $recommendationLink = Resolve-SafeHyperlinkTarget -Target ([string]$rec.Link)
+ if (-not [string]::IsNullOrWhiteSpace($recommendationLink)) {
+ $relationshipId = "rId$nextHyperlinkRelId"
+ $nextHyperlinkRelId++
+ $hyperlinkRelationships += [pscustomobject]@{
+ Id = $relationshipId
+ Target = $recommendationLink
+ }
+ }
- foreach ($cat in $CategoriesList) {
- $categoryLabel = $cat.Category
- $pfx = $cat.Prefix
+ $recommendationItems += [pscustomobject]@{
+ Text = $recText
+ RelationshipId = $relationshipId
+ }
+ }
- #$categoryData = $data | Where-Object { $_.ReportingCategoryPrefix -eq $pfx } # 3 categories
- $categoryData = $data | Where-Object { $_.$categoryField -eq $cat.Prefix} # 12 categories
- $categoryDataCount = ($categoryData | Measure-Object).Count
- $categoryScore = $cat.CategoryScore
+ if ($recommendationItems.Count -eq 0) {
+ $recommendationItems += [pscustomobject]@{
+ Text = "No recommendations in this category."
+ RelationshipId = $null
+ }
+ }
- $categoryDescription = ""
- $matchingDesc = $descriptionsFile | Where-Object { $_.Category -eq $cat.Prefix }
- if ($matchingDesc -and -not [string]::IsNullOrWhiteSpace($matchingDesc.Description)) {
- $categoryDescription = $matchingDesc.Description
+ $categoryDescription = if (-not [string]::IsNullOrWhiteSpace($categoryRow.Description)) { [string]$categoryRow.Description } else {
+ @(
+ "Average Score: $([math]::Round($averageScore, 0))"
+ "Recommendations In Category: $($categoryRows.Count)"
+ "Recommendations >= ${MinimumReportLevel}: $highCount"
+ ) -join "`n"
+ }
+ $recommendationHeader = "Top $($categoryTop.Count) out of $($categoryRows.Count) recommendations:"
+
+ $slideXml = Get-Content -LiteralPath $detailTemplateSlidePath -Raw
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Title -Text $categoryRow.Caption -FontSize "2400" -Bold $true
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Score -Text ([math]::Round($averageScore, 0).ToString()) -FontSize "2400" -Bold $true -Alignment "ctr"
+ $slideXml = Set-PptxShapeOffsetXByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.ScoreIndicator -LeftPoints ($averageScore * 2.48 + 38)
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Description -Text $categoryDescription -FontSize "1000" -Alignment "just"
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Header -Text $recommendationHeader -FontSize "1600" -Bold $true
+ $slideXml = Set-PptxShapeHyperlinkItemsByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Recommendations -Items $recommendationItems -FontSize "2000"
+ Add-ManualSlidePart -SlideXml $slideXml -SourceRelsPath $detailTemplateSlideRelsPath -ExternalHyperlinks $hyperlinkRelationships
+ }
}
+ else {
+ foreach ($pillarGroup in ($groupedCategories | Group-Object -Property AssessmentCategory)) {
+ $pillarName = $pillarGroup.Name
+ $pillarRows = @($pillarGroup.Group | ForEach-Object { $_.Group } | ForEach-Object { $_ })
+ $pillarWeights = $pillarRows | Select-Object -ExpandProperty Weight
+ $pillarInfo = Get-PillarInfo -pillar $pillarName
+ $pillarScore = if ($pillarInfo -and -not [string]::IsNullOrWhiteSpace($pillarInfo.Score)) { [string]$pillarInfo.Score } elseif ($pillarWeights.Count -gt 0) { [math]::Round((($pillarWeights | Measure-Object -Average).Average), 0).ToString() } else { $OverallScore }
+ $pillarDescription = if ($pillarInfo -and -not [string]::IsNullOrWhiteSpace($pillarInfo.Description)) { [string]$pillarInfo.Description } else { "$pillarName assessment summary." }
+
+ $titleSlideXml = Get-Content -LiteralPath $titleTemplateSlidePath -Raw
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.PillarName -Text $pillarName -FontSize "4000" -Bold $true
+ $titleSlideXml = Set-PptxShapeTextByName -SlideXml $titleSlideXml -ShapeName $templateMap.TitleSlide.ReportDate -Text "Report generated: $LocalReportDate" -FontSize "1200"
+ Add-ManualSlidePart -SlideXml $titleSlideXml -SourceRelsPath $titleTemplateSlideRelsPath
+
+ $summaryRows = @()
+ foreach ($categoryGroup in ($pillarGroup.Group | Sort-Object -Property ReportingCategory)) {
+ $categoryWeights = $categoryGroup.Group | Select-Object -ExpandProperty Weight
+ $categoryScore = if ($categoryWeights.Count -gt 0) { (($categoryWeights | Measure-Object -Average).Average) } else { 0 }
+ $highCount = ($categoryGroup.Group | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $summaryRows += [pscustomobject]@{
+ ReportingCategory = $categoryGroup.ReportingCategory
+ Caption = GetMappedReportingCategory -reportingCategrory $categoryGroup.ReportingCategory -currentPillar $pillarName
+ Score = $categoryScore
+ HighCount = $highCount
+ }
+ }
+ for ($summaryStart = 0; $summaryStart -lt $summaryRows.Count; $summaryStart += 12) {
+ $summaryPageRows = @($summaryRows | Select-Object -Skip $summaryStart -First 12)
+ $summarySlideXml = Get-Content -LiteralPath $summaryTemplateSlidePath -Raw
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Score -Text $pillarScore -FontSize "2400" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.Description -Text $pillarDescription -FontSize "1100" -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeOffsetXByName -SlideXml $summarySlideXml -ShapeName $templateMap.SummarySlide.ScoreIndicator -LeftPoints ((ConvertTo-DoubleOrZero $pillarScore) * 2.47 + 56)
+
+ for ($rowIndex = 1; $rowIndex -le $templateMap.SummarySlide.Rows.Count; $rowIndex++) {
+ $rowShapes = $templateMap.SummarySlide.Rows[$rowIndex - 1]
+ $row = if ($rowIndex -le $summaryPageRows.Count) { $summaryPageRows[$rowIndex - 1] } else { $null }
+ if ($null -ne $row) {
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count -Text ([string]$row.HighCount) -FontSize "1200" -Bold $true -Alignment "ctr"
+ $summarySlideXml = Set-PptxShapeTextByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label -Text ([string]$row.Caption) -FontSize "1200"
+ $gaugeRelationshipId = if ($row.Score -lt 33) { $templateMap.SummarySlide.GaugeRelationships.Green } elseif ($row.Score -lt 67) { $templateMap.SummarySlide.GaugeRelationships.Yellow } else { $templateMap.SummarySlide.GaugeRelationships.Red }
+ $summarySlideXml = Set-PptxPictureBlipByName -SlideXml $summarySlideXml -PictureName $rowShapes.Gauge -RelationshipId $gaugeRelationshipId
+ }
+ else {
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Count
+ $summarySlideXml = Remove-PptxShapeByName -SlideXml $summarySlideXml -ShapeName $rowShapes.Label
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $rowShapes.Gauge
+ }
+ }
+ foreach ($templateGaugeName in $templateMap.SummarySlide.TemplateGauges) {
+ $summarySlideXml = Remove-PptxObjectByName -SlideXml $summarySlideXml -ObjectName $templateGaugeName
+ }
+ Add-ManualSlidePart -SlideXml $summarySlideXml -SourceRelsPath $summaryTemplateSlideRelsPath
+ }
- $y = $categoryDataCount
- $x = $ShowTop
- if ($categoryDataCount -lt $x) {
- $x = $categoryDataCount
- }
+ foreach ($categoryGroup in ($pillarGroup.Group | Sort-Object -Property ReportingCategory)) {
+ $categoryName = GetMappedReportingCategory -reportingCategrory $categoryGroup.ReportingCategory -currentPillar $pillarName
+ $categoryRows = $categoryGroup.Group
+ $weights = $categoryRows | Select-Object -ExpandProperty Weight
+ $averageScore = (($weights | Measure-Object -Average).Average)
+ $highCount = ($categoryRows | Where-Object { $_.Weight -ge $MinimumReportLevel } | Measure-Object).Count
+ $categoryTop = $categoryRows |
+ Sort-Object -Property Weight -Descending |
+ Select-Object -Unique -Property "Link-Text", Link, Weight |
+ Select-Object -First $ShowTop
+
+ $recommendationItems = @()
+ $hyperlinkRelationships = @()
+ $detailTemplateRelIds = if (Test-Path $detailTemplateSlideRelsPath) { [regex]::Matches((Get-Content -LiteralPath $detailTemplateSlideRelsPath -Raw), 'Id="rId(\d+)"') } else { @() }
+ $nextHyperlinkRelId = 1
+ if ($detailTemplateRelIds.Count -gt 0) {
+ $nextHyperlinkRelId = (($detailTemplateRelIds | ForEach-Object { [int]$_.Groups[1].Value } | Measure-Object -Maximum).Maximum) + 1
+ }
- $newDetailSlide = $detailSlide.Duplicate()
- $newDetailSlide.MoveTo($presentation.Slides.Count)
+ foreach ($rec in $categoryTop) {
+ $recText = ($rec.'Link-Text' | Out-String).Trim()
+ if ([string]::IsNullOrWhiteSpace($recText)) {
+ $recText = "(no recommendation title provided)"
+ }
+
+ $relationshipId = $null
+ $recommendationLink = Resolve-SafeHyperlinkTarget -Target ([string]$rec.Link)
+ if (-not [string]::IsNullOrWhiteSpace($recommendationLink)) {
+ $relationshipId = "rId$nextHyperlinkRelId"
+ $nextHyperlinkRelId++
+ $hyperlinkRelationships += [pscustomobject]@{
+ Id = $relationshipId
+ Target = $recommendationLink
+ }
+ }
+
+ $recommendationItems += [pscustomobject]@{
+ Text = $recText
+ RelationshipId = $relationshipId
+ }
+ }
- $newDetailSlide.Shapes("CategoryLabel").TextFrame.TextRange.Text = $categoryLabel
- $newDetailSlide.Shapes("CategoryScore").TextFrame.TextRange.Text = $categoryScore.ToString("#")
-
- # Calculate bar position without type casting
- $scoreNum = 0
- if ($categoryScore -is [double] -or $categoryScore -is [int]) {
- $scoreNum = $categoryScore
- } else {
- $scoreNum = [float]::Parse($categoryScore.ToString())
- }
- $detailBarScore = ($scoreNum * 2.48) + 38
- $newDetailSlide.Shapes("ScoreIndicator").Left = $detailBarScore
-
- $newDetailSlide.Shapes("CategoryDescription").TextFrame.TextRange.Text = $categoryDescription
- $newDetailSlide.Shapes("TOPRecommendations").TextFrame.TextRange.Text = "Top $x out of $y recommendations:"
+ if ($recommendationItems.Count -eq 0) {
+ $recommendationItems += [pscustomobject]@{
+ Text = "No recommendations in this category."
+ RelationshipId = $null
+ }
+ }
- $recoShape = $null
- try {
- if ($newDetailSlide.Shapes.Count -ge 8) {
- $tmp = $newDetailSlide.Shapes.Item(8)
- if ($tmp.HasTextFrame -eq -1) { $recoShape = $tmp }
- }
- } catch {}
-
- if (-not $recoShape) {
- $best = $null
- [double]$bestArea = 0
- for ($i = 1; $i -le $newDetailSlide.Shapes.Count; $i++) {
- $s = $newDetailSlide.Shapes.Item($i)
- if ($s.HasTextFrame -eq -1) {
- [double]$area = $s.Width * $s.Height
- if ($area -gt $bestArea) { $best = $s; $bestArea = $area }
+ $mappedDescription = ($descriptionsFile | Where-Object { $_.Pillar -eq $pillarName -and $_.Category.StartsWith($categoryGroup.ReportingCategory) } | Select-Object -First 1).Description
+ $categoryDescription = if (-not [string]::IsNullOrWhiteSpace($mappedDescription)) { [string]$mappedDescription } else {
+ @(
+ "Average Score: $([math]::Round($averageScore, 0))"
+ "Recommendations In Category: $($categoryRows.Count)"
+ "Recommendations >= ${MinimumReportLevel}: $highCount"
+ ) -join "`n"
+ }
+ $recommendationHeader = "Top $($categoryTop.Count) out of $($categoryRows.Count) recommendations:"
+
+ $slideXml = Get-Content -LiteralPath $detailTemplateSlidePath -Raw
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Title -Text $categoryName -FontSize "2400" -Bold $true
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Score -Text ([math]::Round($averageScore, 0).ToString()) -FontSize "2400" -Bold $true -Alignment "ctr"
+ $slideXml = Set-PptxShapeOffsetXByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.ScoreIndicator -LeftPoints ($averageScore * 2.48 + 38)
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Description -Text $categoryDescription -FontSize "1000" -Alignment "just"
+ $slideXml = Set-PptxShapeTextByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Header -Text $recommendationHeader -FontSize "1600" -Bold $true
+ $slideXml = Set-PptxShapeHyperlinkItemsByName -SlideXml $slideXml -ShapeName $templateMap.DetailSlide.Recommendations -Items $recommendationItems -FontSize "2000"
+ Add-ManualSlidePart -SlideXml $slideXml -SourceRelsPath $detailTemplateSlideRelsPath -ExternalHyperlinks $hyperlinkRelationships
}
}
- $recoShape = $best
}
- if (-not $recoShape) {
- throw "Could not locate the recommendations textbox"
+ $endSlideXml = Get-Content -LiteralPath $endTemplateSlidePath -Raw
+ $endSlideXml = $endSlideXml.Replace("[Name]", "").Replace("[Title]", "").Replace("[Organization Name]", "")
+ Add-ManualSlidePart -SlideXml $endSlideXml -SourceRelsPath $endTemplateSlideRelsPath
+
+ foreach ($templateSlideNumber in @($templateMap.TitleSlide.SourceSlide, $templateMap.SummarySlide.SourceSlide, $templateMap.DetailSlide.SourceSlide, $templateMap.EndSlide.SourceSlide)) {
+ $templateSlideName = "slide$templateSlideNumber.xml"
+ $templateTarget = "slides/$templateSlideName"
+ $targetPattern = [regex]::Escape($templateTarget)
+ $presentationRels = [regex]::Replace($presentationRels, "]*Target=`"$targetPattern`"[^>]*/>", "")
+ $contentTypesXml = [regex]::Replace($contentTypesXml, "]*/>", "")
+
+ $templateSlidePath = Join-Path $slidesDir $templateSlideName
+ $templateSlideRelsPath = Join-Path $slideRelsDir "$templateSlideName.rels"
+ $notesSlideRelsDir = Join-Path $tempRoot "ppt/notesSlides/_rels"
+ if (Test-Path $notesSlideRelsDir) {
+ foreach ($notesSlideRelsFile in Get-ChildItem -LiteralPath $notesSlideRelsDir -Filter "notesSlide*.xml.rels") {
+ $notesSlideRelsXml = Get-Content -LiteralPath $notesSlideRelsFile.FullName -Raw
+ if ($notesSlideRelsXml -match "Target=`"\.\./slides/$templateSlideName`"") {
+ $notesSlideFileName = $notesSlideRelsFile.Name -replace '\.rels$', ''
+ $notesSlidePath = Join-Path $tempRoot "ppt/notesSlides/$notesSlideFileName"
+ $contentTypesXml = [regex]::Replace($contentTypesXml, "]*/>", "")
+ Remove-Item -LiteralPath $notesSlideRelsFile.FullName -Force
+ if (Test-Path $notesSlidePath) {
+ Remove-Item -LiteralPath $notesSlidePath -Force
+ }
+ }
+ }
+ }
+ if (Test-Path $templateSlidePath) {
+ Remove-Item -LiteralPath $templateSlidePath -Force
+ }
+ if (Test-Path $templateSlideRelsPath) {
+ Remove-Item -LiteralPath $templateSlideRelsPath -Force
+ }
}
+ Set-Content -LiteralPath $presentationXmlPath -Value $presentationXml -NoNewline
+ Set-Content -LiteralPath $presentationRelsPath -Value $presentationRels -NoNewline
+ Set-Content -LiteralPath $contentTypesPath -Value $contentTypesXml -NoNewline
- #$recoShape.TextFrame.TextRange.Text = ($categoryData | Sort-Object -Property "Link-Text" -Unique | Sort-Object -Property Weight -Descending | Select-Object -First $x).'Link-Text' -join "`r`n`r`n"
- $sortedRecommendations = $categoryData | Sort-Object -Property Weight -Descending | Select-Object -First $x
+ foreach ($slideFile in (Get-ChildItem -LiteralPath $slidesDir -Filter "slide*.xml")) {
+ $slideXml = Get-Content -LiteralPath $slideFile.FullName -Raw
+ $slideXml = $slideXml.Replace("[Name]", "").Replace("[Title]", "").Replace("[Organization Name]", "")
+ Set-Content -LiteralPath $slideFile.FullName -Value $slideXml -NoNewline
+ }
- # Trim all Link-Text to remove trailing spaces
- $trimmedText = $sortedRecommendations | ForEach-Object { $_.'Link-Text'.Trim() }
- $recoShape.TextFrame.TextRange.Text = $trimmedText -join "`r`n`r`n"
+ [System.IO.Compression.ZipFile]::CreateFromDirectory($tempRoot, $outputPath)
- $lastFoundRange = $null
- foreach ($rec in $sortedRecommendations) {
- $recText = $rec.'Link-Text'.Trim()
- try {
- # Find the text in the shape (or find next occurrence)
- if ($null -eq $lastFoundRange) {
- $textRange = $recoShape.TextFrame.TextRange.Find($recText)
- }
- else {
- $textRange = $recoShape.TextFrame.TextRange.Find($recText, $lastFoundRange.Start + $lastFoundRange.Length)
- }
-
- if ($textRange) {
- $textRange.ActionSettings(1).HyperLink.Address = $rec.Link
- $lastFoundRange = $textRange
- }
- else {
- Write-Warning "Could not find text in shape: $recText"
- }
- }
- catch {
- Write-Warning "Failed to set hyperlink for: $recText - Error: $_"
+ if (Test-Path $outputPath) {
+ $packageIssues = @(Test-PptxPackage -Path $outputPath)
+ if ($packageIssues.Count -gt 0) {
+ Write-Error "OpenXML PPTX generation produced an invalid package: $($packageIssues -join '; ')"
+ return $false
}
+
+ Write-Host "OpenXML PPTX generation completed." -ForegroundColor Green
+ Write-Host "Output file: $outputPath" -ForegroundColor Green
+ return $true
}
- }
- }
-Function CleanUp
-{
- try {
- $newEndSlide = $endSlide.Duplicate()
- $newEndSlide.MoveTo($presentation.Slides.Count)
- $titleSlide.Delete()
- $summarySlide.Delete()
- $detailSlide.Delete()
- $endSlide.Delete()
+ Write-Error "OpenXML PPTX generation did not produce expected output file: $outputPath"
+ return $false
}
catch {
- Write-Warning "Error during slide cleanup: $_"
+ Write-Error "OpenXML PPTX generation failed: $($_.Exception.Message)"
+ return $false
+ }
+ finally {
+ if (Test-Path $tempRoot) {
+ Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ [gc]::Collect()
}
+}
+
+function Get-FileName($initialDirectory) {
+ <#
+.SYNOPSIS
+ Shows a Windows file picker for selecting an assessment CSV.
- # Build output path
- $safeReportDate = ($reportDate.ToString() -replace '[\\\/:\*\?"<>\|]', '-')
+.PARAMETER initialDirectory
+ Directory shown when the file picker opens.
- if ($WellArchitected) {
- $outputFileName = "WAF-Review-$safeReportDate.pptx"
- }
- elseif ($DevOpsCapability) {
- $outputFileName = "DevOps-$safeReportDate.pptx"
- }
- elseif ($GenAI) {
- $outputFileName = "GenAI-$safeReportDate.pptx"
- }
- else {
- $outputFileName = "CASA-$safeReportDate.pptx"
- }
+.OUTPUTS
+ Selected CSV file path.
+#>
+ [System.Reflection.Assembly]::LoadWithPartialName("System.windows.forms") | Out-Null
+
+ $OpenFileDialog = New-Object System.Windows.Forms.OpenFileDialog
+ $OpenFileDialog.initialDirectory = $initialDirectory
+ $OpenFileDialog.filter = "CSV (*.csv)| *.csv"
+ $OpenFileDialog.Title = "Select review file export"
+ $OpenFileDialog.ShowDialog() | Out-Null
+ $OpenFileDialog.filename
+}
- $outputPath = Join-Path $workingDirectory $outputFileName
+function FindIndexBeginningWith($stringset, $searchterm) {
+ <#
+.SYNOPSIS
+ Finds the index of the first string that starts with a search term.
- # Remove existing file if present
- if (Test-Path $outputPath) {
- try {
- Remove-Item -LiteralPath $outputPath -Force
- Write-Host "Removed existing file: $outputFileName"
- }
- catch {
- Write-Warning "Could not remove existing file: $_"
+.PARAMETER stringset
+ Collection of strings to search.
+
+.PARAMETER searchterm
+ Prefix to match.
+
+.OUTPUTS
+ Zero-based index of the first matching string, or $false when no match exists.
+#>
+ $i = 0
+ foreach ($line in $stringset) {
+ if ($line.StartsWith($searchterm)) {
+ return $i
}
+ $i++
}
+ return false
+}
- # Try SaveAs first (more reliable than SaveCopyAs)
- $saveSuccess = $false
- try {
- $presentation.SaveAs($outputPath)
- $saveSuccess = $true
- Write-Host "Saving presentation..."
- }
- catch {
- Write-Warning "SaveAs failed: $($_.Exception.Message)"
-
- # Fallback to SaveCopyAs
+
+function LoadDescriptionFile {
+ <#
+.SYNOPSIS
+ Loads the category description CSV for the selected assessment type.
+
+.DESCRIPTION
+ Selects the WAF, DevOps, GenAI, or CAF category description file from the script directory and validates that it exists before importing it.
+
+.OUTPUTS
+ Imported category description rows.
+#>
+ if ($WellArchitected) {
+ $descriptionPath = Join-Path $workingDirectory "WAF Category Descriptions.csv"
+ Assert-RequiredFile -Path $descriptionPath -Description "WAF category description file"
try {
- $presentation.SaveCopyAs($outputPath)
- $saveSuccess = $true
- Write-Host "Saving presentation (using SaveCopyAs)..."
+ $descriptionsFile = Import-Csv -LiteralPath $descriptionPath
}
catch {
- Write-Warning "SaveCopyAs also failed: $($_.Exception.Message)"
+ Write-Error -Message "Unable to open $descriptionPath"
+ exit
}
}
-
- # Verify the file was created
- if (!(Test-Path $outputPath)) {
- Write-Error "Output file was not created at: $outputPath"
- }
-
- # Close presentation and quit PowerPoint (suppress expected errors)
- try {
- if ($presentation) {
- $presentation.Close()
+ elseif ($DevOpsCapability) {
+ $descriptionPath = Join-Path $workingDirectory "DevOps Category Descriptions.csv"
+ Assert-RequiredFile -Path $descriptionPath -Description "DevOps category description file"
+ try {
+ $descriptionsFile = Import-Csv -LiteralPath $descriptionPath
}
- }
- catch {
- # Silently continue - process will be force-closed anyway
- }
-
- # Quit PowerPoint application
- try {
- if ($application) {
- $application.Quit()
+ catch {
+ Write-Error -Message "Unable to open $descriptionPath"
+ exit
}
}
- catch {
- # Silently continue - process will be force-closed anyway
- }
-
- # Release COM objects explicitly
- if ($presentation) {
+ elseif ($GenAI) {
+ $descriptionPath = Join-Path $workingDirectory "GenAI Category Descriptions.csv"
+ Assert-RequiredFile -Path $descriptionPath -Description "GenAI category description file"
try {
- [void][System.Runtime.Interopservices.Marshal]::ReleaseComObject($presentation)
+ $descriptionsFile = Import-Csv -LiteralPath $descriptionPath
}
catch {
- Write-Warning "Error releasing presentation COM object: $_"
+ Write-Error -Message "Unable to open $descriptionPath"
+ exit
}
}
-
- if ($application) {
+ else {
+ $descriptionPath = Join-Path $workingDirectory "CAF Category Descriptions.csv"
+ Assert-RequiredFile -Path $descriptionPath -Description "CAF category description file"
try {
- [void][System.Runtime.Interopservices.Marshal]::ReleaseComObject($application)
+ $descriptionsFile = Import-Csv -LiteralPath $descriptionPath
}
catch {
- Write-Warning "Error releasing application COM object: $_"
+ Write-Error -Message "Unable to open $descriptionPath"
+ exit
}
}
+ return $descriptionsFile
- # Clean up variables at script scope
- Remove-Variable -Name presentation -Scope Script -ErrorAction SilentlyContinue
- Remove-Variable -Name application -Scope Script -ErrorAction SilentlyContinue
- Remove-Variable -Name titleSlide -Scope Script -ErrorAction SilentlyContinue
- Remove-Variable -Name summarySlide -Scope Script -ErrorAction SilentlyContinue
- Remove-Variable -Name detailSlide -Scope Script -ErrorAction SilentlyContinue
- Remove-Variable -Name endSlide -Scope Script -ErrorAction SilentlyContinue
-
- # Force garbage collection
- [gc]::Collect()
- [gc]::WaitForPendingFinalizers()
- [gc]::Collect()
-
- # Brief wait for COM cleanup
- Start-Sleep -Milliseconds 500
-
- # Delete the temporary template file (with retry logic)
- if ($tempTemplatePath -and (Test-Path $tempTemplatePath)) {
- $deleteAttempts = 0
- $maxAttempts = 5
- $deleted = $false
-
- while ($deleteAttempts -lt $maxAttempts -and -not $deleted) {
- try {
- Remove-Item -Path $tempTemplatePath -Force -ErrorAction Stop
- $deleted = $true
- Write-Host "Temporary template file deleted"
- } catch {
- $deleteAttempts++
- if ($deleteAttempts -lt $maxAttempts) {
- Start-Sleep -Milliseconds 500
- } else {
- Write-Host "WARNING: Could not delete temp file after $maxAttempts attempts: $tempTemplatePath" -ForegroundColor Yellow
- Write-Host "You may need to delete it manually." -ForegroundColor Yellow
- }
- }
- }
- }
-
- # Check if PowerPoint is still running and force close if needed
- $ppProcesses = Get-Process -Name "POWERPNT" -ErrorAction SilentlyContinue
+}
-
- # Check if PowerPoint is still running and force close if needed
- $ppProcesses = Get-Process -Name "POWERPNT" -ErrorAction SilentlyContinue
- if ($ppProcesses) {
- Write-Host "Closing PowerPoint process..."
- foreach ($proc in $ppProcesses) {
- try {
- $proc.Kill()
- $proc.WaitForExit(2000) | Out-Null
- Write-Host "PowerPoint closed (PID: $($proc.Id))"
- }
- catch {
- Write-Warning "Could not close PowerPoint process: $_"
- }
- }
+
+function Get-PillarInfo($pillar) {
+ <#
+.SYNOPSIS
+ Gets score and description metadata for a Well-Architected pillar.
+
+.PARAMETER pillar
+ Pillar name from the assessment data.
+
+.OUTPUTS
+ Object containing pillar name, score, description, and score description.
+#>
+ if ($pillar.Contains("Cost Optimization")) {
+ return [pscustomobject]@{"Pillar" = $pillar; "Score" = $costScore; "Description" = $costDescription; "ScoreDescription" = $OverallScoreDescription }
}
-
- if ($saveSuccess -or (Test-Path $outputPath)) {
- Write-Host "`nReport generation completed successfully!" -ForegroundColor Green
- Write-Host "Output file: $outputPath" -ForegroundColor Green
+ if ($pillar.Contains("Reliability")) {
+ return [pscustomobject]@{"Pillar" = $pillar; "Score" = $reliabilityScore; "Description" = $reliabilityDescription; "ScoreDescription" = $ReliabilityScoreDescription }
+ }
+ if ($pillar.Contains("Operational Excellence")) {
+ return [pscustomobject]@{"Pillar" = $pillar; "Score" = $operationsScore; "Description" = $operationsDescription; "ScoreDescription" = $OperationsScoreDescription }
+ }
+ if ($pillar.Contains("Performance Efficiency")) {
+ return [pscustomobject]@{"Pillar" = $pillar; "Score" = $performanceScore; "Description" = $performanceDescription; "ScoreDescription" = $PerformanceScoreDescription }
+ }
+ if ($pillar.Contains("Security")) {
+ return [pscustomobject]@{"Pillar" = $pillar; "Score" = $securityScore; "Description" = $securityDescription; "ScoreDescription" = $SecurityScoreDescription }
}
}
-#endregion
+function GetMappedReportingCategory {
+ <#
+.SYNOPSIS
+ Maps a reporting category to its friendly caption for a pillar.
+.PARAMETER reportingCategrory
+ Reporting category value from the assessment CSV.
-#region Main
+.PARAMETER currentPillar
+ Pillar used to find the category description row.
+
+.OUTPUTS
+ Friendly category caption, or the original reporting category when no mapping exists.
+#>
+ param (
+ $reportingCategrory,
+ $currentPillar
+ )
+
+ $newReportingCategory = ($descriptionsFile | Where-Object { $_.Pillar -eq $currentPillar -and $_.Category.StartsWith($reportingCategrory) }).Caption
+ if (-not $newReportingCategory) {
+ $newReportingCategory = $reportingCategrory # Fallback to existing ReportingCategory if no mapping found
+ }
+
+ return $newReportingCategory
+}
+#endregion
-$workingDirectory = (Get-Location).Path #Get the working directory from the script
+#region Main
+$scriptDirectory = if ([string]::IsNullOrWhiteSpace($PSScriptRoot)) { (Get-Location).Path } else { $PSScriptRoot }
+$workingDirectory = $scriptDirectory
+$outputDirectory = Resolve-ReportOutputDirectory -Path $OutputDirectory
$content = OpenAssessmentFile
$assessmentTypeCheck = ""
$assessmentTypeCheck = ($content | Select-Object -First 1)
$reportDate = Get-Date -Format "yyyy-MM-dd-HHmm"
-$tempTemplatePath = $null # Temp template file to prevent modifying original
$localReportDate = Get-Date -Format g
$overallScore = ""
$costScore = ""
@@ -1366,10 +2345,12 @@ else {
if ($WellArchitected) {
Write-host "Producing Well Architected report from $global:assessmentFile"
- $templatePresentation = "$workingDirectory\PnP_PowerPointReport_Template.pptx"
+ $templatePresentation = Join-Path $workingDirectory "PnP_PowerPointReport_Template.pptx"
+ Assert-RequiredFile -Path $templatePresentation -Description "WAF report template"
$title = "Well-Architected [pillar] Assessment" # Don't edit this - it's used when multiple Pillars are included.
+ $expectedHeader = "Category,Link-Text,Link,Priority,ReportingCategory,ReportingSubcategory,Weight,Context"
try {
- $tableStart = FindIndexBeginningWith $content "Category,Link-Text,Link,Priority,ReportingCategory,ReportingSubcategory,Weight,Context"
+ $tableStart = FindIndexBeginningWith $content $expectedHeader
}
catch {
Write-host "That appears not to be a content file. Please use only content from the Well-Architected Assessment site."
@@ -1377,11 +2358,13 @@ if ($WellArchitected) {
try {
$EndStringIdentifier = $content | Where-Object { $_.Contains("--,,") } | Select-Object -Unique -First 1
+ Assert-ContentSection -TableStart $tableStart -EndStringIdentifier $EndStringIdentifier -ExpectedHeader $expectedHeader
$tableEnd = $content.IndexOf($EndStringIdentifier) - 1
+ $assessmentCsvPath = Join-Path $outputDirectory "$reportDate.csv"
- $csv = $content[$tableStart..$tableEnd] | Out-File "$workingDirectory\$reportDate.csv"
- $importdata = Import-Csv -Path "$workingDirectory\$reportDate.csv"
+ $content[$tableStart..$tableEnd] | Out-File -LiteralPath $assessmentCsvPath
+ $importdata = Import-Csv -LiteralPath $assessmentCsvPath
Write-Host "Processing assessment data..."
# Clean the uncategorized data
@@ -1393,10 +2376,9 @@ if ($WellArchitected) {
}
}
- $data = $importdata | where { $_.Category -in $filteredPillars }
- $data | Export-Csv -UseQuotes AsNeeded "$workingDirectory\$reportDate.csv"
- $data | % { $_.Weight = [int]$_.Weight }
- $pillars = $data.Category | Select-Object -Unique
+ $data = $importdata | Where-Object { $_.Category -in $filteredPillars }
+ $data | Export-Csv -UseQuotes AsNeeded -LiteralPath $assessmentCsvPath
+ $data | ForEach-Object { $_.Weight = [int]$_.Weight }
}
catch {
Write-Host "Unable to parse the content file."
@@ -1409,32 +2391,35 @@ if ($WellArchitected) {
else {
if ($CloudAdoption) {
Write-host "Producing Cloud Adoption Security Assessment report from $global:assessmentFile"
- $templatePresentation = "$workingDirectory\PnP_PowerPointReport_Template - CAF-Secure.pptx"
+ $templatePresentation = Join-Path $workingDirectory "PnP_PowerPointReport_Template - CAF-Secure.pptx"
$title = "Cloud Adoption Security Assessment"
}
elseif ($DevOpsCapability) {
Write-host "Producing DevOps Capability Review report from $global:assessmentFile"
- $templatePresentation = "$workingDirectory\PnP_PowerPointReport_Template - DevOps.pptx"
+ $templatePresentation = Join-Path $workingDirectory "PnP_PowerPointReport_Template - DevOps.pptx"
$title = "DevOps Capability Review"
}
elseif ($GenAI) {
Write-host "Producing GenAI Workload Security Assessment report from $global:assessmentFile"
- $templatePresentation = "$workingDirectory\PnP_PowerPointReport_Template - GenAI.pptx"
+ $templatePresentation = Join-Path $workingDirectory "PnP_PowerPointReport_Template - GenAI.pptx"
$title = "GenAI Workload Security Assessment"
}
+ Assert-RequiredFile -Path $templatePresentation -Description "$title report template"
try {
- $tableStart = FindIndexBeginningWith $content "Category,Link-Text,Link,Priority,ReportingCategory,ReportingSubcategory,Weight,Context,CompleteY/N,Note"
+ $expectedHeader = "Category,Link-Text,Link,Priority,ReportingCategory,ReportingSubcategory,Weight,Context,CompleteY/N,Note"
+ $tableStart = FindIndexBeginningWith $content $expectedHeader
#Write-Debug "Tablestart: $tablestart"
$EndStringIdentifier = $content | Where-Object { $_.Contains("--,,") } | Select-Object -Unique -First 1
+ Assert-ContentSection -TableStart $tableStart -EndStringIdentifier $EndStringIdentifier -ExpectedHeader $expectedHeader
#Write-Debug "EndStringIdentifier: $EndStringIdentifier"
$tableEnd = $content.IndexOf($EndStringIdentifier) - 1
+ $assessmentCsvPath = Join-Path $outputDirectory "$reportDate.csv"
#Write-Debug "Tableend: $tableend"
- $csv = $content[$tableStart..$tableEnd] | Out-File "$workingDirectory\$reportDate.csv"
- $data = Import-Csv -Path "$workingDirectory\$reportDate.csv"
+ $content[$tableStart..$tableEnd] | Out-File -LiteralPath $assessmentCsvPath
+ $data = Import-Csv -LiteralPath $assessmentCsvPath
Write-Host "Processing assessment data..."
- $data | % { $_.Weight = [int]$_.Weight }
- #$pillars = $data.Category | Select-Object -Unique
+ $data | ForEach-Object { $_.Weight = [int]$_.Weight }
}
catch {
Write-Host "Unable to parse the content file."
@@ -1445,23 +2430,8 @@ else {
}
}
-
$descriptionsFile = LoadDescriptionFile
-# Clean up any existing PowerPoint COM objects from previous runs
-if ($application) {
- try {
- $application.Quit()
- [System.Runtime.Interopservices.Marshal]::ReleaseComObject($application) | Out-Null
- } catch {}
-}
-Remove-Variable -Name application, presentation, titleSlide, summarySlide, detailSlide, endSlide -ErrorAction SilentlyContinue
-[gc]::Collect()
-[gc]::WaitForPendingFinalizers()
-Start-Sleep -Milliseconds 500
-
-
-
$cloudAdoptionDescription = ($descriptionsFile | Where-Object { $_.Category -eq "Survey Level Group" }).Description
$devOpsDescription = ($descriptionsFile | Where-Object { $_.Category -eq "Survey Level Group" }).Description
$genAIDescription = ($descriptionsFile | Where-Object { $_.Category -eq "Survey Level Group" }).Description
@@ -1471,60 +2441,28 @@ $performanceDescription = ($descriptionsFile | Where-Object { $_.Pillar -eq "Per
$reliabilityDescription = ($descriptionsFile | Where-Object { $_.Pillar -eq "Reliability" -and $_.Category -eq "Survey Level Group" }).Description
$securityDescription = ($descriptionsFile | Where-Object { $_.Pillar -eq "Security" -and $_.Category -eq "Survey Level Group" }).Description
-
-#region Instantiate PowerPoint variables
-
-Write-Host "Launching PowerPoint..."
-
-# Create a temporary copy of the template to prevent modifying the original
-$tempTemplatePath = Join-Path $workingDirectory "~temp_template_$reportDate.pptx"
-try {
- Copy-Item -Path $templatePresentation -Destination $tempTemplatePath -Force
- Write-Host "Created temporary template copy"
-} catch {
- Write-Error "Failed to create template copy: $_"
+$openXmlAssessmentKind = if ($CloudAdoption) { "CASA" } elseif ($DevOpsCapability) { "DevOps" } elseif ($GenAI) { "GenAI" } else { "WAF" }
+$openXmlSummaryDescription = if ($CloudAdoption) { $cloudAdoptionDescription } elseif ($DevOpsCapability) { $devOpsDescription } elseif ($GenAI) { $genAIDescription } else { "" }
+
+$validationResult = Export-AssessmentReportPptxOpenXml `
+ -WorkingDirectory $outputDirectory `
+ -ReportDate $reportDate `
+ -LocalReportDate $localReportDate `
+ -TemplatePresentation $templatePresentation `
+ -AssessmentKind $openXmlAssessmentKind `
+ -AssessmentTitle $title `
+ -SummaryDescription $openXmlSummaryDescription `
+ -OverallScore $overallScore `
+ -Data $data `
+ -ShowTop $ShowTop `
+ -MinimumReportLevel $MinimumReportLevel
+
+if ($validationResult) {
+ Write-Host "Report generation completed."
exit
}
-$application = New-Object -ComObject powerpoint.application
-$application.visible = -1
-
-# Open the TEMPORARY copy instead of the original template
-$presentation = $application.Presentations.open($tempTemplatePath)
-$presentation.Saved = -1
-
-
-if ($WellArchitected) {
- $titleSlide = $presentation.Slides[9]
- $summarySlide = $presentation.Slides[10]
- $detailSlide = $presentation.Slides[11]
- $endSlide = $presentation.Slides[12]
-}
-else {
- $titleSlide = $presentation.Slides[3]
- $summarySlide = $presentation.Slides[4]
- $detailSlide = $presentation.Slides[5]
- $endSlide = $presentation.Slides[6]
-}
-
-Write-Host "Generating report slides..."
-
-#endregion
-
-
-if ($WellArchitected) {
- WellArchitectedAssessment
-}
-elseif ($DevOpsCapability) {
- DevOpsCapabilityAssessment
-}
-elseif ($GenAI) {
- GenAIAssessment
-}
-else {
- CloudAdoptionAssessment
-}
-
-CleanUp
+Write-Error "Report generation failed."
+exit
#endregion
\ No newline at end of file
diff --git a/WARP/devops/PnP_PowerPointReport_Template - CAF-Secure.pptx b/WARP/devops/PnP_PowerPointReport_Template - CAF-Secure.pptx
old mode 100644
new mode 100755
index 3b0bcf6..b13f57d
Binary files a/WARP/devops/PnP_PowerPointReport_Template - CAF-Secure.pptx and b/WARP/devops/PnP_PowerPointReport_Template - CAF-Secure.pptx differ
diff --git a/WARP/devops/PnP_PowerPointReport_Template.pptx b/WARP/devops/PnP_PowerPointReport_Template.pptx
old mode 100644
new mode 100755
index 1241cf7..a15963f
Binary files a/WARP/devops/PnP_PowerPointReport_Template.pptx and b/WARP/devops/PnP_PowerPointReport_Template.pptx differ
diff --git a/WARP/devops/README.md b/WARP/devops/README.md
index 9c4f5d9..4a5de99 100644
--- a/WARP/devops/README.md
+++ b/WARP/devops/README.md
@@ -49,19 +49,15 @@ There are four sections to this document:
- After testing, users of this example script are encouraged to import recommendations into an appropriate GitHub project for work planning and execution.
-- Windows 10 or greater.
+- Windows 10 or greater, macOS, or Linux.
- PowerShell v7
-- Microsoft PowerPoint 2019
-
- - PowerPoint is not required for importing findings into Azure DevOps or GitHub.
-
- - Only required for creating PowerPoint slideshows outlining the issues found.
+- PowerPoint is not required for generating reports or importing findings into Azure DevOps or GitHub.
---
-**IMPORTANT:** **These instructions only work in a Windows environment.**
+**IMPORTANT:** **Report generation uses an OpenXML process and works cross-platform with PowerShell 7. PowerPoint is not required. On non-Windows platforms, run the report script with `-ContentFile` because the interactive file picker is Windows-only.**
### Download scripts and prepare your environment to run them.
@@ -112,12 +108,36 @@ There are four sections to this document:
.\GenerateAssessmentReport.ps1
```
+ On non-Windows platforms, provide the CSV path explicitly:
+
+ ```powershell
+ ./GenerateAssessmentReport.ps1 -ContentFile ./Azure_Well_Architected_Review_Sample.csv
+ ```
+
+ To write the generated PPTX and filtered CSV to a specific directory, use `-OutputDirectory`:
+
+ ```powershell
+ ./GenerateAssessmentReport.ps1 -ContentFile ./Azure_Well_Architected_Review_Sample.csv -OutputDirectory ./reports
+ ```
+
**NOTE:** A new PowerPoint file will be created in the directory with name in the format of: `WAF-Review-yyyy-MM-dd-HHmm.pptx`
1. Examine this PowerPoint file for auto-generated slides after slide 8.
1. If these slides are created in this deck, then your environment is properly set up and you may move now use the above steps with a CSV generated by your WAF assessment.
+### Report generation troubleshooting
+
+| Symptom | What to check |
+| --- | --- |
+| `Template file not found` | Ensure the downloaded template PPTX files remain in the same directory as `GenerateAssessmentReport.ps1`. The script resolves templates from its own directory, not from the terminal's current directory. |
+| `Input file does not contain the expected assessment table` | Verify the CSV was exported from the assessment site and still contains the expected header row. Do not remove the metadata rows before the recommendation table. |
+| `Output directory is not writable` | Use `-OutputDirectory` with a directory you can write to, or omit it to write to the current directory. |
+| `Template contract validation failed` | The PPTX template is missing required named shapes or gauge relationships. Re-download the templates, or verify that edited templates preserve the shape names used by the script. |
+| Hyperlinks are missing from some recommendations | Only `https` and `http` links are written to the PPTX. Unsupported or invalid link schemes are skipped with a warning. |
+
+The report templates are part of the script contract. If you edit a template in PowerPoint, preserve the named shapes on the title, summary, detail, and end slides. The OpenXML generator validates those names before creating a report so template drift fails early.
+
## Import recommendations into an Azure DevOps project
1. Create or log into an Azure DevOps **Organization**: