src/report/renderers/Export-Pdf.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Render a real, self-contained .pdf assessment report (cover, executive summary, per-area findings table, prioritized gaps, manual review) with zero external/network dependencies. Retained tier. .DESCRIPTION ADO Stories AB#379/394/395. OFFLINE-PDF APPROACH (investigated and chosen deliberately): every other heavyweight renderer in this folder either shells out to a module (ImportExcel) or acquires a NuGet package at first use (Export-Pptx's DocumentFormat.OpenXml). A real .pdf does not need either -- the PDF file format itself is an openly documented, text-and-binary object graph (a small set of numbered objects, a cross-reference table, and a trailer), and Adobe's 14 base fonts (Helvetica/Helvetica-Bold here) are guaranteed present in every PDF-conformant reader without embedding a font program. This renderer hand-assembles that object graph directly with core .NET (System.Text.Encoding, System.Collections.Generic.List[byte]) -- no DocumentFormat.OpenXml, no PDFsharp/iText/QuestPDF, no dotnet/NuGet acquire step, no network call, ever. That is the "prefer pure-.NET/no network" option from the brief, taken all the way rather than falling back to a document-model + HTML-print stand-in. A print-optimized, self-contained HTML fallback (Export-ScoutPdfHtmlFallback, below) still exists, but purely as a non-fatal safety net if the hand-rolled PDF assembly throws for an unanticipated reason -- it is not the primary path, and Export-Pdf's own Pester coverage exercises the real .pdf path end-to-end, so the fallback is not expected to trigger in normal operation. If it ever does, the produced file is named *_fallback.html and states plainly that it is a fallback, not a silently-renamed non-PDF artifact. HONEST LIMITATIONS of this hand-rolled writer (documented rather than hidden): - ASCII-only text content. Every string drawn on the page is escaped through ConvertTo-ScoutPdfLiteral, which passes through printable ASCII (0x20-0x7E) and renders anything else (accented characters, 真, emoji, smart quotes, ...) as '?'. Real Unicode glyph embedding needs an embedded font program (TrueType/CFF) with a CID mapping -- a large undertaking deliberately out of scope here; findings/titles in this platform's rule files are ASCII today, so this is a theoretical gap, not an observed one. - No text measurement/kerning: layout is left-aligned with fixed character-width truncation heuristics (ScoutPdfTruncate), not real glyph-width metrics. Good enough for a tabular assessment report, not a typeset document. - "Status emoji" (AB#395) are rendered as small vector-drawn colored dots (Add-ScoutPdfDot), not literal Unicode emoji glyphs -- Helvetica (a base-14 Type1 font) has no emoji glyphs at all, and embedding a color-emoji font is its own multi-week project. A colored status dot next to the text label reads the same way at a glance and renders identically in every PDF viewer, which a hoped-for-but-missing emoji glyph would not. - Content streams are NOT Flate-compressed (no /Filter on page content streams). This trades file size for the ability to grep the raw PDF bytes for literal content-stream text -- which both this file's own Pester tests and any future debugging session rely on. A compression pass could be added later without touching the object model. - AB#379 (diagram embed): this renderer CAN embed a diagram -- but only a baseline JPEG (1 or 3 color components), because JPEG is the one common image format a PDF can embed by copying its compressed bytes as-is (/Filter /DCTDecode) with no decode step at all. There is no diagram-capture mechanism anywhere in this codebase (no headless browser, no draw.io export automation) to produce that JPEG automatically, and adding one is out of this renderer's scope/file ownership. So "if feasible" resolves to: drop a `diagram.jpg` (or `.jpeg`) into the run's -OutputPath folder before rendering, and this renderer will size it into the cover page automatically (Get-ScoutPdfDiagram/Add-ScoutPdfImage); absent that file, the cover page prints an explicit "not available" note instead of silently omitting the section. PNG source diagrams are not supported directly (that needs a pixel decoder this renderer intentionally does not carry) -- convert to JPEG first. CULTURE SAFETY: every numeric token written into a PDF content stream or object dictionary goes through ScoutPdfNum, which formats with [CultureInfo]::InvariantCulture. Plain PowerShell string interpolation of a [double] uses the CURRENT THREAD CULTURE, which renders "12.5" as "12,5" on a comma-decimal locale (e.g. de-DE) -- that would corrupt PDF number syntax outright. This is exercised directly in tests/Report.Pdf.Tests.ps1 by switching the thread culture mid-test. .PARAMETER Findings The scored Findings object from Get-Score (GeneratedOn/Frameworks/Areas/ Gaps/Manual/Errors/Findings). .PARAMETER Collect The raw Collect object. Only Collect._meta.scope/.managementGroupId are read (same minimal-read contract Export-Pptx/Export-React use). .PARAMETER OutputPath Folder to write assessment_report.pdf into. Created if missing. Also where an optional diagram.jpg/.jpeg is looked up for AB#379. .OUTPUTS [string] the full path to the written .pdf (or the fallback .html if the primary path threw). Never throws -- failures are non-fatal by design so a broken renderer never sinks an otherwise-good assessment run. .NOTES Tracks ADO Stories AB#379, AB#394, AB#395. #> # ---- palette (RGB triples, 0..1) -- names carry a "Pdf" infix so they never # collide with Export-Pptx.ps1's identically-purposed but differently-typed # $Script:Navy/etc (hex strings) even though every renderer in this folder is # dot-sourced into one shared module scope by AzureScout.psm1. ---- $Script:ScoutPdfNavy = @(0.122, 0.306, 0.471) $Script:ScoutPdfSteel = @(0.180, 0.459, 0.714) $Script:ScoutPdfGreen = @(0.180, 0.490, 0.196) $Script:ScoutPdfGold = @(0.722, 0.525, 0.043) $Script:ScoutPdfRed = @(0.690, 0.000, 0.125) $Script:ScoutPdfInk = @(0.102, 0.102, 0.102) $Script:ScoutPdfGray = @(0.349, 0.349, 0.349) $Script:ScoutPdfWhite = @(1.000, 1.000, 1.000) $Script:ScoutPdfSeverityRank = @{ high = 0; medium = 1; low = 2 } #region Safe data access / formatting helpers function Get-ScoutPdfProp { param($Obj, [Parameter(Mandatory)][string]$Name, $Default = $null) if ($null -eq $Obj) { return $Default } $p = $Obj.PSObject.Properties[$Name] if ($p) { return $p.Value } else { return $Default } } function Get-ScoutPdfMeta { # Mirrors Export-React's/Export-Pptx's minimal-read contract: only ever # touches Collect._meta.scope / .managementGroupId, defensively (Collect # may be $null or a plain deserialized object missing either key). param($Collect) $scope = $null; $mgId = $null if ($null -ne $Collect) { $metaProp = $Collect.PSObject.Properties['_meta'] if ($metaProp -and $null -ne $metaProp.Value) { $metaSrc = $metaProp.Value $scopeProp = $metaSrc.PSObject.Properties['scope'] if ($scopeProp) { $scope = $scopeProp.Value } $mgProp = $metaSrc.PSObject.Properties['managementGroupId'] if ($mgProp) { $mgId = $mgProp.Value } } } return [pscustomobject]@{ Scope = $scope; ManagementGroupId = $mgId } } function ScoutPdfNum { # Culture-safe numeric formatting for PDF content-stream/object tokens -- # see the file header's CULTURE SAFETY note. Never use plain string # interpolation of a [double] anywhere else in this file. param([double]$D, [int]$Decimals = 2) return [math]::Round($D, $Decimals).ToString(('F' + $Decimals), [System.Globalization.CultureInfo]::InvariantCulture) } function ScoutPdfTruncate { param([string]$Text, [int]$MaxChars) if (-not $Text) { return '' } if ($Text.Length -le $MaxChars) { return $Text } if ($MaxChars -le 3) { return $Text.Substring(0, [math]::Max($MaxChars, 0)) } return $Text.Substring(0, $MaxChars - 3) + '...' } function Split-ScoutPdfWrap { param([string]$Text, [int]$MaxChars = 88) if (-not $Text) { return , @('') } $words = $Text -split '\s+' $lines = [System.Collections.Generic.List[string]]::new() $cur = '' foreach ($w in $words) { $candidate = if ($cur) { "$cur $w" } else { $w } if ($candidate.Length -gt $MaxChars -and $cur) { $lines.Add($cur) $cur = $w } else { $cur = $candidate } } if ($cur) { $lines.Add($cur) } if ($lines.Count -eq 0) { $lines.Add('') } return , $lines } function ConvertTo-ScoutPdfLiteral { # Escapes a string for a PDF "(...)" literal AND enforces the ASCII-only # content-stream limitation documented in the file header. param([string]$Text) if ($null -eq $Text) { $Text = '' } $sb = [System.Text.StringBuilder]::new() foreach ($ch in $Text.ToCharArray()) { $code = [int]$ch if ($ch -eq '(' -or $ch -eq ')' -or $ch -eq '\') { [void]$sb.Append('\').Append($ch) } elseif ($code -eq 10 -or $code -eq 13) { [void]$sb.Append(' ') } elseif ($code -ge 32 -and $code -le 126) { [void]$sb.Append($ch) } else { [void]$sb.Append('?') } } return $sb.ToString() } #endregion #region Status / severity / score color-banding (independent of, and named # distinctly from, Export-Pptx's identically-purposed Get-ScoutSeverity*/ # Get-ScoutScoreColor helpers -- see palette-collision note above) function Get-ScoutPdfStatusColor { param($Status) if (-not $Status) { return $Script:ScoutPdfGray } switch ($Status.ToString().Trim().ToLowerInvariant()) { 'pass' { return $Script:ScoutPdfGreen } 'partial' { return $Script:ScoutPdfGold } 'fail' { return $Script:ScoutPdfRed } 'manual' { return $Script:ScoutPdfSteel } default { return $Script:ScoutPdfGray } } } function Get-ScoutPdfStatusLabel { param($Status) if ($Status -and "$Status".Trim()) { return "$Status".ToUpperInvariant() } return 'UNKNOWN' } function Get-ScoutPdfSeverityColor { param($Severity) if (-not $Severity) { return $Script:ScoutPdfGray } switch ($Severity.ToString().Trim().ToLowerInvariant()) { 'high' { return $Script:ScoutPdfRed } 'medium' { return $Script:ScoutPdfGold } 'low' { return $Script:ScoutPdfSteel } default { return $Script:ScoutPdfGray } } } function Get-ScoutPdfSeverityLabel { param($Severity) if ($Severity -and "$Severity".Trim()) { return "$Severity".ToUpperInvariant() } return 'UNKNOWN' } function Get-ScoutPdfSeverityRank { param($Severity) if ($Severity) { $key = $Severity.ToString().Trim().ToLowerInvariant() if ($Script:ScoutPdfSeverityRank.ContainsKey($key)) { return $Script:ScoutPdfSeverityRank[$key] } } return 99 } function Get-ScoutPdfScoreColor { param($Score) if ($null -eq $Score) { return $Script:ScoutPdfGray } if ($Score -ge 80) { return $Script:ScoutPdfGreen } if ($Score -ge 50) { return $Script:ScoutPdfGold } return $Script:ScoutPdfRed } #endregion #region Page context (a mutable Hashtable -- cheap reference-type mutation # across every helper below, no Add-Member ceremony needed) function New-ScoutPdfContext { return @{ Pages = [System.Collections.Generic.List[string]]::new() Ops = [System.Text.StringBuilder]::new() Y = 738.0 Margin = 54.0 Top = 738.0 Bottom = 60.0 Started = $false HeaderRepeat = $null # scriptblock invoked right after each new page's chrome is drawn -- AB#394 hook FooterLabel = $null } } function Add-ScoutPdfRaw { param($Ctx, [string]$Op) [void] $Ctx.Ops.Append($Op).Append("`n") } function Complete-ScoutPdfPage { param($Ctx) [void] $Ctx.Pages.Add($Ctx.Ops.ToString()) $Ctx.Ops = [System.Text.StringBuilder]::new() } function Start-ScoutPdfPage { # Begins a new page: flushes whatever the previous page had drawn, resets # the Y cursor, draws the thin footer rule/label/page-number chrome, then # -- the AB#394 page-break-aware header repeat -- re-invokes # $Ctx.HeaderRepeat (if one is set) so a table's column header re-appears # at the top of every continuation page, not just the first. param($Ctx, [string]$FooterLabel = $null) if ($FooterLabel) { $Ctx.FooterLabel = $FooterLabel } elseif (-not $Ctx.FooterLabel) { $Ctx.FooterLabel = 'Azure Scout Assessment Report' } if ($Ctx.Started) { Complete-ScoutPdfPage $Ctx } $Ctx.Started = $true $Ctx.Y = $Ctx.Top $pageNum = $Ctx.Pages.Count + 1 Add-ScoutPdfLineSeg $Ctx $Ctx.Margin 40 (612 - $Ctx.Margin) 40 @(0.83, 0.83, 0.83) 0.75 Add-ScoutPdfText $Ctx $Ctx.Margin 26 $Ctx.FooterLabel 8 'F1' $Script:ScoutPdfGray Add-ScoutPdfText $Ctx (612 - $Ctx.Margin - 40) 26 "Page $pageNum" 8 'F1' $Script:ScoutPdfGray if ($Ctx.HeaderRepeat) { & $Ctx.HeaderRepeat $Ctx } } function ScoutPdfEnsureSpace { # Page-break trigger: if the next NeededPt of content would run past the # bottom margin, start a fresh page first (which re-fires HeaderRepeat). param($Ctx, [double]$Needed) if (($Ctx.Y - $Needed) -lt $Ctx.Bottom) { Start-ScoutPdfPage -Ctx $Ctx } } #endregion #region Low-level drawing primitives (every numeric token routed through # ScoutPdfNum -- see CULTURE SAFETY in the file header) function Add-ScoutPdfText { param($Ctx, [double]$X, [double]$Y, [string]$Text, [double]$Size = 10, [string]$Font = 'F1', [double[]]$Color = @(0, 0, 0)) $lit = ConvertTo-ScoutPdfLiteral $Text Add-ScoutPdfRaw $Ctx ("{0} {1} {2} rg" -f (ScoutPdfNum $Color[0] 3), (ScoutPdfNum $Color[1] 3), (ScoutPdfNum $Color[2] 3)) Add-ScoutPdfRaw $Ctx 'BT' Add-ScoutPdfRaw $Ctx ("/{0} {1} Tf" -f $Font, (ScoutPdfNum $Size)) Add-ScoutPdfRaw $Ctx ("{0} {1} Td" -f (ScoutPdfNum $X), (ScoutPdfNum $Y)) Add-ScoutPdfRaw $Ctx ("($lit) Tj") Add-ScoutPdfRaw $Ctx 'ET' } function Add-ScoutPdfRect { param($Ctx, [double]$X, [double]$Y, [double]$W, [double]$H, [double[]]$Color) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} rg" -f (ScoutPdfNum $Color[0] 3), (ScoutPdfNum $Color[1] 3), (ScoutPdfNum $Color[2] 3)) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} {3} re f" -f (ScoutPdfNum $X), (ScoutPdfNum $Y), (ScoutPdfNum $W), (ScoutPdfNum $H)) } function Add-ScoutPdfLineSeg { param($Ctx, [double]$X1, [double]$Y1, [double]$X2, [double]$Y2, [double[]]$Color = @(0, 0, 0), [double]$Width = 0.75) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} RG" -f (ScoutPdfNum $Color[0] 3), (ScoutPdfNum $Color[1] 3), (ScoutPdfNum $Color[2] 3)) Add-ScoutPdfRaw $Ctx ("{0} w" -f (ScoutPdfNum $Width)) Add-ScoutPdfRaw $Ctx ("{0} {1} m" -f (ScoutPdfNum $X1), (ScoutPdfNum $Y1)) Add-ScoutPdfRaw $Ctx ("{0} {1} l" -f (ScoutPdfNum $X2), (ScoutPdfNum $Y2)) Add-ScoutPdfRaw $Ctx 'S' } function Add-ScoutPdfDot { # The AB#395 "status emoji" stand-in -- a small filled circle approximated # with four cubic Beziers (kappa = 0.5523*r), not a text glyph. See the # file header's honest-limitations note for why. param($Ctx, [double]$Cx, [double]$Cy, [double]$R, [double[]]$Color) $k = 0.5523 * $R Add-ScoutPdfRaw $Ctx ("{0} {1} {2} rg" -f (ScoutPdfNum $Color[0] 3), (ScoutPdfNum $Color[1] 3), (ScoutPdfNum $Color[2] 3)) Add-ScoutPdfRaw $Ctx ("{0} {1} m" -f (ScoutPdfNum ($Cx + $R)), (ScoutPdfNum $Cy)) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} {3} {4} {5} c" -f (ScoutPdfNum ($Cx + $R)), (ScoutPdfNum ($Cy + $k)), (ScoutPdfNum ($Cx + $k)), (ScoutPdfNum ($Cy + $R)), (ScoutPdfNum $Cx), (ScoutPdfNum ($Cy + $R))) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} {3} {4} {5} c" -f (ScoutPdfNum ($Cx - $k)), (ScoutPdfNum ($Cy + $R)), (ScoutPdfNum ($Cx - $R)), (ScoutPdfNum ($Cy + $k)), (ScoutPdfNum ($Cx - $R)), (ScoutPdfNum $Cy)) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} {3} {4} {5} c" -f (ScoutPdfNum ($Cx - $R)), (ScoutPdfNum ($Cy - $k)), (ScoutPdfNum ($Cx - $k)), (ScoutPdfNum ($Cy - $R)), (ScoutPdfNum $Cx), (ScoutPdfNum ($Cy - $R))) Add-ScoutPdfRaw $Ctx ("{0} {1} {2} {3} {4} {5} c" -f (ScoutPdfNum ($Cx + $k)), (ScoutPdfNum ($Cy - $R)), (ScoutPdfNum ($Cx + $R)), (ScoutPdfNum ($Cy - $k)), (ScoutPdfNum ($Cx + $R)), (ScoutPdfNum $Cy)) Add-ScoutPdfRaw $Ctx 'f' } function Add-ScoutPdfImage { param($Ctx, [double]$X, [double]$Y, [double]$W, [double]$H) Add-ScoutPdfRaw $Ctx 'q' Add-ScoutPdfRaw $Ctx ("{0} 0 0 {1} {2} {3} cm" -f (ScoutPdfNum $W), (ScoutPdfNum $H), (ScoutPdfNum $X), (ScoutPdfNum $Y)) Add-ScoutPdfRaw $Ctx '/Im1 Do' Add-ScoutPdfRaw $Ctx 'Q' } #endregion #region Layout helpers -- the AB#395 "sub-section/bullet/status-emoji # helpers". Every one of these manages its own $Ctx.Y cursor decrement and # calls ScoutPdfEnsureSpace first, so callers never touch $Ctx.Y directly. function Add-ScoutPdfHeading { param($Ctx, [string]$Text, [double]$Size = 24, [string]$Font = 'F2', [double[]]$Color = $Script:ScoutPdfNavy) ScoutPdfEnsureSpace $Ctx ($Size * 1.4) Add-ScoutPdfText $Ctx $Ctx.Margin $Ctx.Y $Text $Size $Font $Color $Ctx.Y -= ($Size * 1.4) } function Add-ScoutPdfSubsection { # A bold heading + a short gold underline rule -- used for every major # section (Executive Summary / Findings by Area / Prioritized Gaps / # Manual Review Items / Architecture Diagram). param($Ctx, [string]$Text) ScoutPdfEnsureSpace $Ctx 30 Add-ScoutPdfText $Ctx $Ctx.Margin $Ctx.Y $Text 15 'F2' $Script:ScoutPdfNavy $underlineY = $Ctx.Y - 4 Add-ScoutPdfLineSeg $Ctx $Ctx.Margin $underlineY ($Ctx.Margin + 60) $underlineY $Script:ScoutPdfGold 2 $Ctx.Y -= 24 } function Add-ScoutPdfLine { param($Ctx, [string]$Text, [double]$Size = 10, [string]$Font = 'F1', [double[]]$Color = $Script:ScoutPdfInk) ScoutPdfEnsureSpace $Ctx 14 Add-ScoutPdfText $Ctx $Ctx.Margin $Ctx.Y $Text $Size $Font $Color $Ctx.Y -= 14 } function Add-ScoutPdfBullet { # Word-wraps $Text to $MaxChars-wide lines; only the first line gets the # marker (a colored status/severity dot when $DotColor is supplied, else # a plain small square). Used throughout the exec summary, gaps, and # manual-review sections. param($Ctx, [string]$Text, [double[]]$DotColor = $null, [int]$MaxChars = 88) $lines = @(Split-ScoutPdfWrap -Text $Text -MaxChars $MaxChars) for ($li = 0; $li -lt $lines.Count; $li++) { ScoutPdfEnsureSpace $Ctx 14 if ($li -eq 0) { if ($DotColor) { Add-ScoutPdfDot $Ctx ($Ctx.Margin + 3) ($Ctx.Y + 3) 3.2 $DotColor } else { Add-ScoutPdfRect $Ctx $Ctx.Margin ($Ctx.Y + 2) 4 4 $Script:ScoutPdfGray } } Add-ScoutPdfText $Ctx ($Ctx.Margin + 12) $Ctx.Y $lines[$li] 10 'F1' $Script:ScoutPdfInk $Ctx.Y -= 14 } } function Add-ScoutPdfTableHeader { # The AB#394 "page-break-aware header repeat" is this function, wired up # as $Ctx.HeaderRepeat by Export-Pdf before the findings table begins -- # Start-ScoutPdfPage re-invokes it on every continuation page. param($Ctx, [double[]]$ColX) ScoutPdfEnsureSpace $Ctx 18 Add-ScoutPdfRect $Ctx $Ctx.Margin ($Ctx.Y - 2) (612 - 2 * $Ctx.Margin) 16 $Script:ScoutPdfNavy $labels = @('ID', 'Severity', 'Status', 'Title') for ($i = 0; $i -lt $labels.Count; $i++) { Add-ScoutPdfText $Ctx ($ColX[$i] + 2) ($Ctx.Y + 2) $labels[$i] 9 'F2' $Script:ScoutPdfWhite } $Ctx.Y -= 18 } function Add-ScoutPdfAreaHeading { param($Ctx, [string]$Text, $Score) ScoutPdfEnsureSpace $Ctx 16 $scoreTxt = if ($null -eq $Score) { '(not scored)' } else { "Score: $Score" } Add-ScoutPdfText $Ctx $Ctx.Margin $Ctx.Y "$Text -- $scoreTxt" 10 'F2' $Script:ScoutPdfSteel $Ctx.Y -= 15 } function Add-ScoutPdfTableRow { param($Ctx, [double[]]$ColX, $Finding) ScoutPdfEnsureSpace $Ctx 14 $status = Get-ScoutPdfProp $Finding 'Status' 'Unknown' $sev = Get-ScoutPdfProp $Finding 'Severity' $null $id = "$(Get-ScoutPdfProp $Finding 'Id' '')" $title = "$(Get-ScoutPdfProp $Finding 'Title' '')" Add-ScoutPdfText $Ctx ($ColX[0] + 2) $Ctx.Y (ScoutPdfTruncate $id 13) 9 'F1' $Script:ScoutPdfInk Add-ScoutPdfText $Ctx ($ColX[1] + 2) $Ctx.Y (Get-ScoutPdfSeverityLabel $sev) 9 'F1' (Get-ScoutPdfSeverityColor $sev) Add-ScoutPdfDot $Ctx ($ColX[2] + 5) ($Ctx.Y + 3) 3.2 (Get-ScoutPdfStatusColor $status) Add-ScoutPdfText $Ctx ($ColX[2] + 12) $Ctx.Y (Get-ScoutPdfStatusLabel $status) 9 'F1' $Script:ScoutPdfInk Add-ScoutPdfText $Ctx ($ColX[3] + 2) $Ctx.Y (ScoutPdfTruncate $title 58) 9 'F1' $Script:ScoutPdfInk $Ctx.Y -= 14 } #endregion #region AB#379 diagram embed -- baseline-JPEG-only, no decode step (see the # file header's honest-limitations note on why PNG is out of scope here) function Get-ScoutPdfJpegInfo { # Minimal JPEG marker scan: finds the first SOFn (start-of-frame) marker # and reads precision/height/width/component-count directly out of it. # Returns $null for anything that isn't a well-formed baseline/progressive # JPEG (SOI marker + a recognizable SOF segment before the scan begins). param([byte[]]$Bytes) if ($null -eq $Bytes -or $Bytes.Length -lt 4 -or $Bytes[0] -ne 0xFF -or $Bytes[1] -ne 0xD8) { return $null } $i = 2 while ($i -lt ($Bytes.Length - 1)) { if ($Bytes[$i] -ne 0xFF) { $i++; continue } $marker = $Bytes[$i + 1] if ($marker -eq 0xFF) { $i++; continue } # padding fill byte if ($marker -eq 0xD8 -or $marker -eq 0xD9) { $i += 2; continue } # SOI/EOI -- no length field if ($marker -ge 0xD0 -and $marker -le 0xD7) { $i += 2; continue } # RSTn -- no length field if ($marker -eq 0xDA) { break } # start-of-scan reached, no SOF found first if ($i + 3 -ge $Bytes.Length) { break } $segLen = ([int]$Bytes[$i + 2] -shl 8) -bor [int]$Bytes[$i + 3] $isSof = ($marker -ge 0xC0 -and $marker -le 0xCF) -and $marker -ne 0xC4 -and $marker -ne 0xC8 -and $marker -ne 0xCC if ($isSof) { $p = $i + 4 if ($p + 5 -ge $Bytes.Length) { return $null } $height = ([int]$Bytes[$p + 1] -shl 8) -bor [int]$Bytes[$p + 2] $width = ([int]$Bytes[$p + 3] -shl 8) -bor [int]$Bytes[$p + 4] $components = $Bytes[$p + 5] return [pscustomobject]@{ Width = $width; Height = $height; Components = $components } } $i = $i + 2 + $segLen } return $null } function Get-ScoutPdfDiagram { # Looks for diagram.jpg/.jpeg directly inside -OutputPath (the run # folder) -- the one convention this renderer defines for AB#379. Nothing # in this codebase produces that file automatically today (no # diagram-capture mechanism exists) -- see the file header note. param([string]$OutputPath) try { $candidate = @('diagram.jpg', 'diagram.jpeg') | ForEach-Object { Join-Path $OutputPath $_ } | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $candidate) { return $null } $bytes = [System.IO.File]::ReadAllBytes($candidate) $info = Get-ScoutPdfJpegInfo -Bytes $bytes if (-not $info -or $info.Width -le 0 -or $info.Height -le 0) { Write-Warning "Export-Pdf: '$candidate' does not look like a valid JPEG (no SOF marker found) -- skipping diagram embed." return $null } $colorSpace = switch ($info.Components) { 1 { 'DeviceGray' } 3 { 'DeviceRGB' } default { $null } } if (-not $colorSpace) { Write-Warning "Export-Pdf: '$candidate' has $($info.Components) color components -- only grayscale (1) or RGB/YCbCr (3) baseline JPEGs can be embedded -- skipping diagram embed." return $null } return [pscustomobject]@{ Bytes = $bytes; Width = $info.Width; Height = $info.Height; ColorSpace = $colorSpace } } catch { Write-Warning "Export-Pdf: could not read diagram image: $_" return $null } } #endregion #region Document assembly -- writes the literal PDF byte stream (header, # numbered objects, xref table, trailer). Content streams are intentionally # left uncompressed (see file header). Every offset is tracked against a real # List[byte].Count, so binary segments (the %-marker line, an embedded JPEG) # mix safely with the ASCII structure/text around them. function New-ScoutPdfDocument { param($Ctx, $Diagram) $bytes = [System.Collections.Generic.List[byte]]::new() $offsets = @{} function AddAscii([string]$S) { $bytes.AddRange([System.Text.Encoding]::ASCII.GetBytes($S)) } function AddRawBytes([byte[]]$B) { $bytes.AddRange($B) } AddAscii "%PDF-1.4`n" AddRawBytes ([byte[]]@(0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A)) # binary-file marker comment, per convention $pageCount = $Ctx.Pages.Count $objCatalog = 1 $objPages = 2 $objFontRegular = 3 $objFontBold = 4 $next = 5 $objImage = $null if ($Diagram) { $objImage = $next; $next++ } $pageObjNums = [System.Collections.Generic.List[int]]::new() $contentObjNums = [System.Collections.Generic.List[int]]::new() for ($i = 0; $i -lt $pageCount; $i++) { $pageObjNums.Add($next); $next++ $contentObjNums.Add($next); $next++ } $objInfo = $next; $next++ $totalObjects = $next # objects are numbered 1..(totalObjects-1) $offsets[$objCatalog] = $bytes.Count AddAscii "$objCatalog 0 obj`n<< /Type /Catalog /Pages $objPages 0 R >>`nendobj`n" $kids = (($pageObjNums | ForEach-Object { "$_ 0 R" })) -join ' ' $offsets[$objPages] = $bytes.Count AddAscii "$objPages 0 obj`n<< /Type /Pages /Kids [ $kids ] /Count $pageCount >>`nendobj`n" $offsets[$objFontRegular] = $bytes.Count AddAscii "$objFontRegular 0 obj`n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`nendobj`n" $offsets[$objFontBold] = $bytes.Count AddAscii "$objFontBold 0 obj`n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>`nendobj`n" if ($Diagram) { $offsets[$objImage] = $bytes.Count AddAscii "$objImage 0 obj`n<< /Type /XObject /Subtype /Image /Width $($Diagram.Width) /Height $($Diagram.Height) /ColorSpace /$($Diagram.ColorSpace) /BitsPerComponent 8 /Filter /DCTDecode /Length $($Diagram.Bytes.Length) >>`nstream`n" AddRawBytes $Diagram.Bytes AddAscii "`nendstream`nendobj`n" } $resourceDict = if ($Diagram) { "/Resources << /Font << /F1 $objFontRegular 0 R /F2 $objFontBold 0 R >> /XObject << /Im1 $objImage 0 R >> >>" } else { "/Resources << /Font << /F1 $objFontRegular 0 R /F2 $objFontBold 0 R >> >>" } for ($i = 0; $i -lt $pageCount; $i++) { $pObj = $pageObjNums[$i]; $cObj = $contentObjNums[$i] $offsets[$pObj] = $bytes.Count AddAscii "$pObj 0 obj`n<< /Type /Page /Parent $objPages 0 R /MediaBox [0 0 612 792] $resourceDict /Contents $cObj 0 R >>`nendobj`n" $opsBytes = [System.Text.Encoding]::ASCII.GetBytes($Ctx.Pages[$i]) $offsets[$cObj] = $bytes.Count AddAscii "$cObj 0 obj`n<< /Length $($opsBytes.Length) >>`nstream`n" AddRawBytes $opsBytes AddAscii "`nendstream`nendobj`n" } $offsets[$objInfo] = $bytes.Count AddAscii "$objInfo 0 obj`n<< /Title (Azure Scout Assessment Report) >>`nendobj`n" $xrefStart = $bytes.Count AddAscii "xref`n0 $totalObjects`n" AddAscii "0000000000 65535 f `n" for ($n = 1; $n -lt $totalObjects; $n++) { AddAscii ("{0:D10} 00000 n `n" -f $offsets[$n]) } AddAscii "trailer`n<< /Size $totalObjects /Root $objCatalog 0 R /Info $objInfo 0 R >>`nstartxref`n$xrefStart`n%%EOF" return $bytes.ToArray() } #endregion #region Fallback (non-fatal safety net only -- see file header) function Export-ScoutPdfHtmlFallback { param($Findings, $Collect, [string] $OutputPath, [string] $Reason) if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $json = ($Findings | ConvertTo-Json -Depth 100) -replace '</', '<\/' $safeReason = $Reason -replace '&', '&' -replace '<', '<' -replace '>', '>' $generatedOn = Get-ScoutPdfProp $Findings 'GeneratedOn' '(unknown)' $html = @" <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Azure Scout Assessment Report (fallback)</title> <style> body { font-family: 'Segoe UI', Arial, sans-serif; color:#1a1a1a; margin: 2rem; } .banner { background:#B00020; color:#fff; padding: 0.75rem 1rem; margin-bottom:1rem; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ccc; padding: 4px 6px; font-size: 12px; text-align: left; } th { background:#1F4E78; color:#fff; } @media print { table { page-break-inside: auto; } tr { page-break-inside: avoid; page-break-after: auto; } thead { display: table-header-group; } } </style> </head> <body> <div class="banner"> PDF generation failed for this run -- this is a print-optimized HTML fallback, not a renamed non-PDF file. Open it and use your browser's Print -> Save as PDF to get a .pdf. Reason: $safeReason </div> <h1>Azure Scout Assessment Report</h1> <p>Generated: $generatedOn</p> <script>window.__FINDINGS__ = $json;</script> </body> </html> "@ $path = Join-Path $OutputPath 'assessment_report_fallback.html' $html | Out-File -FilePath $path -Encoding utf8 return $path } #endregion function Export-Pdf { param($Findings, $Collect, [string] $OutputPath) try { if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $ctx = New-ScoutPdfContext $meta = Get-ScoutPdfMeta $Collect $generatedOn = Get-ScoutPdfProp $Findings 'GeneratedOn' '' $frameworks = @(Get-ScoutPdfProp $Findings 'Frameworks' @()) $areas = @(Get-ScoutPdfProp $Findings 'Areas' @()) $gaps = @(Get-ScoutPdfProp $Findings 'Gaps' @()) $manual = @(Get-ScoutPdfProp $Findings 'Manual' @()) $errors = @(Get-ScoutPdfProp $Findings 'Errors' @()) $allFindings = @(Get-ScoutPdfProp $Findings 'Findings' @()) # ---- AB#379 diagram lookup ---- $diagram = Get-ScoutPdfDiagram -OutputPath $OutputPath $diagramNote = $null if (-not $diagram) { $diagramNote = 'Architecture diagram not embedded for this run: no diagram.jpg/.jpeg was found in the output folder (or it was not a supported baseline JPEG). Place a JPEG-format diagram export at "<OutputPath>/diagram.jpg" before rendering to include one -- PNG sources must be converted to JPEG first, since this renderer embeds JPEG bytes directly with zero external dependencies and carries no PNG pixel decoder.' } # ==== Cover page ==== Start-ScoutPdfPage $ctx -FooterLabel 'Azure Scout Assessment Report' Add-ScoutPdfHeading $ctx 'AZURE SCOUT' 13 'F2' $Script:ScoutPdfSteel Add-ScoutPdfHeading $ctx 'Assessment Report' 26 'F2' $Script:ScoutPdfNavy Add-ScoutPdfLine $ctx "Scope: $(if ($meta.Scope) { $meta.Scope } else { '(unspecified)' })" 11 'F1' $Script:ScoutPdfGray if ($meta.ManagementGroupId) { Add-ScoutPdfLine $ctx "Management Group: $($meta.ManagementGroupId)" 11 'F1' $Script:ScoutPdfGray } Add-ScoutPdfLine $ctx "Generated: $(if ($generatedOn) { $generatedOn } else { '(unknown)' })" 11 'F1' $Script:ScoutPdfGray $ctx.Y -= 6 Add-ScoutPdfSubsection $ctx 'Framework Alignment' if (@($frameworks).Count -eq 0) { Add-ScoutPdfBullet $ctx 'No framework scored for this run.' $Script:ScoutPdfGray } else { foreach ($fw in @($frameworks)) { $fwScore = Get-ScoutPdfProp $fw 'Score' $null $scoreTxt = if ($null -eq $fwScore) { 'not scored' } else { "$fwScore / 100" } Add-ScoutPdfBullet $ctx "$(Get-ScoutPdfProp $fw 'Framework' '?') alignment score: $scoreTxt" (Get-ScoutPdfScoreColor $fwScore) } } $ctx.Y -= 6 Add-ScoutPdfSubsection $ctx 'Architecture Diagram' if ($diagram) { $boxW = 300.0; $boxH = 180.0 $scale = [math]::Min($boxW / $diagram.Width, $boxH / $diagram.Height) $imgW = $diagram.Width * $scale $imgH = $diagram.Height * $scale ScoutPdfEnsureSpace $ctx ($imgH + 10) Add-ScoutPdfImage $ctx $ctx.Margin ($ctx.Y - $imgH) $imgW $imgH $ctx.Y -= ($imgH + 10) } else { Add-ScoutPdfBullet $ctx $diagramNote $Script:ScoutPdfGray } # ==== Executive summary ==== Start-ScoutPdfPage $ctx Add-ScoutPdfSubsection $ctx 'Executive Summary' $areaArr = @($areas) $passSum = ($areaArr | ForEach-Object { Get-ScoutPdfProp $_ 'Pass' 0 } | Measure-Object -Sum).Sum $partialSum = ($areaArr | ForEach-Object { Get-ScoutPdfProp $_ 'Partial' 0 } | Measure-Object -Sum).Sum $failSum = ($areaArr | ForEach-Object { Get-ScoutPdfProp $_ 'Fail' 0 } | Measure-Object -Sum).Sum $manualCount = @($manual).Count $errorCount = @($errors).Count $highGaps = @($gaps | Where-Object { (Get-ScoutPdfSeverityLabel (Get-ScoutPdfProp $_ 'Severity' $null)) -eq 'HIGH' }).Count Add-ScoutPdfBullet $ctx "Areas assessed: $($areaArr.Count)" Add-ScoutPdfBullet $ctx "Rules evaluated -- Pass: $passSum, Partial: $partialSum, Fail: $failSum" $criticalColor = if ($highGaps -gt 0) { $Script:ScoutPdfRed } else { $Script:ScoutPdfGreen } Add-ScoutPdfBullet $ctx "Critical (High severity) gaps: $highGaps" $criticalColor Add-ScoutPdfBullet $ctx "Manual review items pending: $manualCount" Add-ScoutPdfBullet $ctx "Unknown/Error findings (check collector permissions): $errorCount" # ==== Findings by area (AB#394 header repeat lives here) ==== Start-ScoutPdfPage $ctx Add-ScoutPdfSubsection $ctx 'Findings by Area' $colX = @($ctx.Margin, $ctx.Margin + 65, $ctx.Margin + 140, $ctx.Margin + 215) $ctx.HeaderRepeat = { param($c) Add-ScoutPdfTableHeader $c $colX } Add-ScoutPdfTableHeader $ctx $colX $grouped = @($allFindings | Group-Object Framework, Area | Sort-Object Name) if ($grouped.Count -eq 0) { $ctx.HeaderRepeat = $null Add-ScoutPdfLine $ctx 'No findings recorded for this run.' } else { foreach ($grp in $grouped) { $fw0 = $grp.Group[0].Framework $ar0 = $grp.Group[0].Area $areaMatch = $areaArr | Where-Object { $_.Framework -eq $fw0 -and $_.Area -eq $ar0 } | Select-Object -First 1 $areaScore = if ($areaMatch) { Get-ScoutPdfProp $areaMatch 'Score' $null } else { $null } ScoutPdfEnsureSpace $ctx 32 # heading + room for at least one row -- avoid an orphaned heading Add-ScoutPdfAreaHeading $ctx "$fw0 - $ar0" $areaScore foreach ($f in $grp.Group) { Add-ScoutPdfTableRow $ctx $colX $f } } $ctx.HeaderRepeat = $null } # ==== Prioritized gaps ==== Start-ScoutPdfPage $ctx Add-ScoutPdfSubsection $ctx 'Prioritized Gaps' if (@($gaps).Count -eq 0) { Add-ScoutPdfBullet $ctx 'No gaps -- every scorable rule passed.' $Script:ScoutPdfGreen } else { $gapsSorted = @($gaps | Sort-Object @{ Expression = { Get-ScoutPdfSeverityRank (Get-ScoutPdfProp $_ 'Severity' $null) } }, Area) $top = @($gapsSorted | Select-Object -First 25) foreach ($g in $top) { $sev = Get-ScoutPdfProp $g 'Severity' $null $label = "[$(Get-ScoutPdfSeverityLabel $sev)] $(Get-ScoutPdfProp $g 'Area' '') -- $(Get-ScoutPdfProp $g 'Title' '')" Add-ScoutPdfBullet $ctx $label (Get-ScoutPdfSeverityColor $sev) } if (@($gaps).Count -gt $top.Count) { Add-ScoutPdfLine $ctx "... and $((@($gaps).Count) - $top.Count) more (see the JSON/Excel evidence exports for the full list)." 9 'F1' $Script:ScoutPdfGray } } # ==== Manual review ==== Start-ScoutPdfPage $ctx Add-ScoutPdfSubsection $ctx 'Manual Review Items' if (@($manual).Count -eq 0) { Add-ScoutPdfBullet $ctx 'No items require manual review.' $Script:ScoutPdfGreen } else { foreach ($m in @($manual)) { $label = "$(Get-ScoutPdfProp $m 'Area' '') -- $(Get-ScoutPdfProp $m 'Title' '')" Add-ScoutPdfBullet $ctx $label $Script:ScoutPdfSteel } } Complete-ScoutPdfPage $ctx $bytes = New-ScoutPdfDocument -Ctx $ctx -Diagram $diagram $pdfPath = Join-Path $OutputPath 'assessment_report.pdf' [System.IO.File]::WriteAllBytes($pdfPath, $bytes) if ($diagramNote) { Write-Warning "Export-Pdf: $diagramNote" } return $pdfPath } catch { Write-Warning "Export-Pdf: PDF generation failed ($_) -- writing a print-optimized HTML fallback instead." try { return (Export-ScoutPdfHtmlFallback -Findings $Findings -Collect $Collect -OutputPath $OutputPath -Reason $_.Exception.Message) } catch { Write-Warning "Export-Pdf: HTML fallback also failed: $_" return $null } } } |