Creating HTML Reports with PowerShell Part 2
Last time, we kicked off our journey into HTML reporting with PowerShell, exploring the basics of creating HTML content, primarily using ConvertTo-Html
. What I've shown you thus far is probably enough if you only need to the output from a single command. For more complex reports, I think you will want to take advantage of HTML fragments. We'll also look at other reporting features you might want to take advantage of.
I am using PowerShell 7 for my examples. The
ConvertTo-Html
cmdlet in PowerShell 7 has a few more features than the one in Windows PowerShell, so if you are still using Windows PowerShell, you will have to adjust the examples slightly.
Using Fragments
I have always found fragments feature of ConvertTo-Html
to be incredibly useful. Using fragments I can create an HTML document with a mix of tables and lists. Although, I should point out that if you prefer a list, you can use the -As
parameter with ConvertTo-Html
to create a list from any object.
$file = Join-Path -path $env:temp -childpath "winsvc.html"
Get-Service win* | Select-Object Name,Status,DisplayName,StartUpType,Description |
ConvertTo-Html -title "Win Service Report" -As List | Out-File -filepath $file

The -Fragment
parameter is a separate feature. Instead of creating a complete HTML document, it creates a fragment that can be inserted into an existing HTML document. The default format is a table, but you can use -As
to change the format to a list.
I often build reports and use fragments to insert headings. Create an array or generic list to hold the fragments. Add each fragment to the list or array, and use the fragments as the body.
$Computername = $env:COMPUTERNAME
$cimParam = @{
OutVariable = 'c'
ComputerName = $Computername
ClassName = "Win32_OperatingSystem"
}
$fragments = @()
#save the operating system name
$os = Get-CimInstance @cimParam -Property Caption
#ComputerSystem
$cimParam.ClassName = 'Win32_ComputerSystem'
$cs = Get-CimInstance @cimParam |
Select-Object Manufacturer, Model, SystemFamily, SystemSKUNumber, SystemType, NumberOf*, TotalPhysicalMemory
#capture the computername from the CimInstance
$computerName = $c.CimSystemProperties.ServerName
#split the class name and use the second part as the heading,
#e.g. Win32_ComputerSystem becomes ComputerSystem
$fragments += "<h3>$($c.CimClass.CimClassName.split('_')[1])</h3>"
$fragments += $cs | ConvertTo-Html -Fragment -As List
#volumes
$cimParam.ClassName = 'Win32_Volume'
$vol = Get-CimInstance @cimParam -filter "DriveType=3" |
Select-Object Name, Label, FreeSpace, Capacity
$fragments += "<h3>$($c.CimClass.CimClassName.split('_')[1])</h3>"
$fragments += $vol | ConvertTo-Html -Fragment -As Table
#processor
$cimParam.ClassName = 'Win32_Processor'
$cpu = Get-CimInstance @cimParam |
Select-Object DeviceID, Name, Caption, MaxClockSpeed, *CacheSize, NumberOf*, SocketDesignation, *Width, Manufacturer
$fragments += "<h3>$($c.CimClass.CimClassName.split('_')[1])</h3>"
$fragments += $cpu | ConvertTo-Html -Fragment -As List
#memory
$cimParam.ClassName = 'Win32_PhysicalMemory'
$mem = Get-CimInstance @cimParam | Select-Object BankLabel, Capacity, DataWidth, Speed
$fragments += "<h3>$($c.CimClass.CimClassName.split('_')[1])</h3>"
$fragments += $mem | ConvertTo-Html -Fragment -As Table
$title = "System Configuration Report"
#embedding the CSS
#preContent is out of order when using fragments so I'll move it to the head
$head = @"
<Title>$title</Title>
<style>
body { background-color:#FFFFFF;
font-family:Tahoma;
font-size:12pt; }
td, th { border:1px solid black;
border-collapse:collapse; }
th { color:white;
background-color:black; }
table, tr, td, th { padding: 2px; margin: 0px }
tr:nth-child(odd) {background-color: LightGray}
table { width:95%;margin-left:5px; margin-bottom:20px;}
</style>
<h1>$Title</h1>
<h2>$computerName | $($os.caption)</h2>
"@
$post = "<h5><i>Report run $(Get-Date)</i></h5>"
$splat = @{
PostContent = $post
Head = $head
Body = $Fragments
}
$report = Join-Path -Path $env:temp -ChildPath "SystemReport-$Computername.html"
ConvertTo-Html @splat | Out-File $report
I am creating HTML fragments for each CIM class I query. I add a heading for each fragment, and then I add the fragment to the array. At the end of the script, I use ConvertTo-Html
to create the HTML document using the fragments as the body.

Images
A common reporting feature you will likely want to include is images. You may have a company logo, or maybe a graph or chart you've generated. You'll need to use the <img>
tag. At a minimum, you need to specify the src
attribute, which is the path to the image file. Again, you need to consider how the file will be consumed or viewed.
<img src='c:/scripts/server-rack.jpg'>
A source like this only makes sense if I am viewing the report on the same computer where the image is stored. Here is a code sample you can try. Replace the image path with a valid path on your computer. This code is also scaling down the image to 25% of its original size. That is completely optional.
$title = "Win Service Report"
$pre = @"
<h1>Win* Service Report</h1>
<h2>$($env:Computername)</h2>
<img src='c:/scripts/server-rack.jpg' width='25%' height='25%'>
"@
$post = "<h5><i>Report run $(Get-Date)</i></h5>"
$head = @"
<Title>$title</Title>
<style>
body { background-color:#FFFFFF;
font-family:Tahoma;
font-size:10pt; }
td, th { border:1px solid black;
border-collapse:collapse; }
th { color:white;
background-color:black; }
table, tr, td, th { padding: 2px; margin: 0px }
tr:nth-child(odd) {background-color: LightGray}
table { width:95%;margin-left:5px; margin-bottom:20px;}
</style>
"@
$splat = @{
PreContent = $pre
PostContent = $post
Title = 'Win Service Report'
Head = $head
}
Get-Service win* |
Select-Object Name,Status,DisplayName,StartUpType,Description |
ConvertTo-Html @splat |
Out-File -filepath $file

I find a more useful approach is to embed the image in the HTML document. This makes the report self-contained. However this will increase the file size of the final HTML document. You will need to convert the image to a Base64 string and then use that string as the src
attribute.
Here is a helper function that will read an image file and return a Base64 string.
Function encodeImage {
[cmdletbinding()]
Param(
[Parameter(Mandatory,HelpMessage="Path to the image file")]
[ValidateScript({Test-Path $_})]
[ValidatePattern("^(.*\.(jpg|jpeg|png|gif|bmp))$")]
[string]$imagePath
)
if ($PSVersionTable.PSVersion.Major -eq 7) {
$imgBytes = Get-Content $ImagePath -AsByteStream
}
else {
$imgBytes = Get-Content $ImagePath -Encoding Byte
}
$ImageBits = [Convert]::ToBase64String($imgBytes)
$ImageFile = Get-Item $ImagePath
$ImageType = $ImageFile.Extension.Substring(1)
[PSCustomObject]@{
ImageBits = $ImageBits
ImageFile = $ImageFile
ImageType = $ImageType
}
}
Here's the same code as before, except I'm embedding the image and adding a little formatting.
$img = encodeImage C:\scripts\server-rack.jpg
$title = "Win Service Report"
$pre = @"
<h1>Win* Service Report</h1>
<h2>$($env:Computername)</h2>
<Img src='data:image/$($img.ImageType);base64,$($img.ImageBits)' Alt='$($img.ImageFile.Name)' title ='$($img.ImageFile.Name)' style='float:left' width='25%' height='25%' hspace=10>
"@
...
The report looks the same, but now the image is embedded in the HTML document. This means I can send the report to someone else, and they will see the image without needing access to the original file. However, this increased the file size from 3KB to over 2MB. I could mitigate this by using a smaller image or adjusting the image quality and resolution before embedding it.
The file I am using is a 1.6MB file at a resolution of 2560x1920. I used the Image Resizer from Microsoft PowerToys to reduce it to a small file. Using this images reduced the final HTML file size to 98KB. The point is to watch the file size of the images you embed. If you are embedding multiple images, you may want to consider using a smaller image or reducing the resolution.
Adding Metadata
One last item before we wrap up for today. You might want to include some metadata in your HTML report. This is information about the report itself, such as when it is was created, who created it, and any other relevant details. This can be especially useful if you are generating reports as a scheduled process.
I was once presenting on HTML reporting to a user group. One of the attendees mentioned that someone, years ago, had setup a process to generate and email HTML reports. However, no one know where the reports were coming from. Whoever set this up was no longer with the company, and the process was not documented. Ever since hearing this story I always include metadata as a footer in my reports.
The
ConvertTo-Html
cmdlet has a-Meta
parameters, but this is for inserting HTML metadata into the document using<meta>
tags. This is a different type of metadata, although there is the possibility of some overlap.
Here's a code sample that adds metadata to the report. I am using the PostContent
parameter to add a footer with the metadata.
#requires -version 7.5
$imgPath = 'c:\scripts\gazoo.bmp'
#embed the image
$img = encodeImage $imgPath
$reportVersion = '1.2.0'
$file = Join-Path $env:TEMP -ChildPath 'WinServiceReport1.html'
$title = 'Win Service Report'
$pre = @"
<h1>Win* Service Report</h1>
<h2>$($env:Computername)</h2>
<Img src='data:image/$($img.ImageType);base64,$($img.ImageBits)' Alt='$($img.ImageFile.Name)' style='float:left' width='15%' height='15%' hspace=10>
"@
$head = @"
<Title>$title</Title>
<style>
body { background-color:#FFFFFF;
font-family:Tahoma;
font-size:10pt; }
td, th { border:1px solid black;
border-collapse:collapse; }
th { color:white;
background-color:black; }
table, tr, td, th { padding: 2px; margin: 0px }
tr:nth-child(odd) {background-color: LightGray}
table { width:95%;margin-left:5px; margin-bottom:20px;}
.footer {
margin-left: 25px;
font-family: Tahoma;
font-size: 10pt;
font-style: italic;
}
</style>
"@
#metadata about the script that generated the report
$scriptSrc = (Convert-Path $MyInvocation.InvocationName).Replace('\', '/')
$postmeta = @"
<div class='footer'>
<b>Report Date</b> : $(Get-Date -Format U) UTC<br>
<b>Report Run</b> : $($env:USERDOMAIN)\$($env:USERNAME)><br>
<b>Script</b> : $scriptSrc <br>
<b>ScriptVersion</b> : $reportVersion<br>
<b>Source</b> : $env:COMPUTERNAME<br>
</div>
"@
#insert html document metadata
# Accepted meta properties are content-type, default-style, application-name, author, description, generator, keywords, x-ua-compatible, and viewport
$meta = @{
Author = "$($env:USERDOMAIN)\$($env:USERNAME)"
Description = "Report generated by {0} from {1} at {2} UTC. Report {3} version {4}" -f "$env:USERDOMAIN\$env:UserName", $env:COMPUTERNAME, (Get-Date -Format U),$scriptSrc,$reportVersion
keywords = "powershell,service"
}
$splat = @{
PreContent = $pre
PostContent = $postMeta
Title = 'Win Service Report'
Head = $head
Meta = $meta
}
Get-Service win* |
Select-Object Name, Status, DisplayName, StartUpType, Description |
ConvertTo-Html @splat | Out-File -FilePath $file
In my script I am creating a footer with script metadata.
$scriptSrc = (Convert-Path $MyInvocation.InvocationName).Replace('\', '/')
$postmeta = @"
<div class='footer'>
<b>Report Date</b> : $(Get-Date -Format U) UTC<br>
<b>Report Run</b> : $($env:USERDOMAIN)\$($env:USERNAME)><br>
<b>Script</b> : $scriptSrc <br>
<b>ScriptVersion</b> : $reportVersion<br>
<b>Source</b> : $env:COMPUTERNAME<br>
</div>
"@
I am also inserting a custom style class. You can see the .footer
style in the header.

Now I can tell what code created the report and where it came from. I've also inserted this information into the HTML metadata.

Summary
This should give you plenty of information and examples to begin creating your own HTML reports. I have shown you how to use fragments to create more complex reports, how to embed images, and how to add metadata to your reports. These features will help you create more informative and useful reports.
Next time, I'll show you a few alternatives for creating HTML reports that you might find easier to use.