Modules/Private/Export-S2DWordReport.ps1
|
# Word (.docx) report exporter — generates Open XML without requiring Office # Cover page with branded banner, KPI table, color-coded section headers, # alternating-row data tables, and health check cards with status colors. function Export-S2DWordReport { param( [Parameter(Mandatory)] [S2DClusterData] $ClusterData, [Parameter(Mandatory)] [string] $OutputPath, [string] $Author = '', [string] $Company = '', [switch] $IncludeNonPoolDisks ) $dir = Split-Path $OutputPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $cn = $ClusterData.ClusterName $nc = $ClusterData.NodeCount $wf = $ClusterData.CapacityWaterfall $pool = $ClusterData.StoragePool $vols = @($ClusterData.Volumes) $allDisks = @($ClusterData.PhysicalDisks) $disks = if ($IncludeNonPoolDisks) { $allDisks } else { @($allDisks | Where-Object { $_.IsPoolMember -ne $false }) } $hc = @($ClusterData.HealthChecks) $oh = $ClusterData.OverallHealth $date = Get-Date -Format 'MMMM d, yyyy' # ── XML helpers ─────────────────────────────────────────────────────────── function local:Esc { param([string]$s) [System.Security.SecurityElement]::Escape($s) } function local:Para { param( [string]$text, [string]$color = '323130', [int] $sz = 22, [bool] $bold = $false, [string]$align = 'left', [int] $spaceBefore = 60, [int] $spaceAfter = 60 ) $b = if ($bold) { '<w:b/>' } else { '' } "<w:p><w:pPr><w:jc w:val='$align'/><w:spacing w:before='$spaceBefore' w:after='$spaceAfter'/></w:pPr><w:r><w:rPr>$b<w:color w:val='$color'/><w:sz w:val='$sz'/><w:szCs w:val='$sz'/><w:rFonts w:ascii='Segoe UI' w:hAnsi='Segoe UI'/></w:rPr><w:t xml:space='preserve'>$(Esc $text)</w:t></w:r></w:p>" } function local:Spacer { "<w:p><w:pPr><w:spacing w:before='0' w:after='160'/></w:pPr></w:p>" } function local:PageBreak { "<w:p><w:r><w:br w:type='page'/></w:r></w:p>" } # Full-width branded banner — used for cover page and section dividers function local:Banner { param( [string]$line1, [string]$line2 = '', [string]$fill = '003A70', [string]$textColor = 'FFFFFF', [int] $sz1 = 52, [int] $sz2 = 26, [string]$accentColor = 'F7941D' ) $l2xml = if ($line2) { "<w:p><w:pPr><w:jc w:val='center'/><w:spacing w:before='40' w:after='360'/></w:pPr><w:r><w:rPr><w:color w:val='$accentColor'/><w:sz w:val='$sz2'/><w:szCs w:val='$sz2'/><w:rFonts w:ascii='Segoe UI' w:hAnsi='Segoe UI'/></w:rPr><w:t>$(Esc $line2)</w:t></w:r></w:p>" } else { "<w:p><w:pPr><w:spacing w:before='0' w:after='280'/></w:pPr></w:p>" } @" <w:tbl> <w:tblPr> <w:tblW w:w="0" w:type="auto"/> <w:tblBorders> <w:top w:val="none" w:sz="0" w:color="auto"/> <w:left w:val="none" w:sz="0" w:color="auto"/> <w:bottom w:val="none" w:sz="0" w:color="auto"/> <w:right w:val="none" w:sz="0" w:color="auto"/> <w:insideH w:val="none" w:sz="0" w:color="auto"/> <w:insideV w:val="none" w:sz="0" w:color="auto"/> </w:tblBorders> <w:tblCellMar> <w:top w:w="200" w:type="dxa"/> <w:left w:w="280" w:type="dxa"/> <w:bottom w:w="200" w:type="dxa"/> <w:right w:w="280" w:type="dxa"/> </w:tblCellMar> </w:tblPr> <w:tr> <w:tc> <w:tcPr> <w:tcW w:w="0" w:type="auto"/> <w:shd w:val="clear" w:color="auto" w:fill="$fill"/> <w:vAlign w:val="center"/> </w:tcPr> <w:p> <w:pPr><w:jc w:val="center"/><w:spacing w:before="400" w:after="80"/></w:pPr> <w:r> <w:rPr><w:b/><w:color w:val="$textColor"/><w:sz w:val="$sz1"/><w:szCs w:val="$sz1"/><w:rFonts w:ascii="Segoe UI" w:hAnsi="Segoe UI"/></w:rPr> <w:t>$(Esc $line1)</w:t> </w:r> </w:p> $l2xml </w:tc> </w:tr> </w:tbl> "@ } # Section header — narrower, blue bar with left-aligned title function local:SectionHeader { param([string]$title, [string]$fill = '0078D4') @" <w:tbl> <w:tblPr> <w:tblW w:w="0" w:type="auto"/> <w:tblBorders> <w:top w:val="none" w:sz="0" w:color="auto"/> <w:left w:val="none" w:sz="0" w:color="auto"/> <w:bottom w:val="none" w:sz="0" w:color="auto"/> <w:right w:val="none" w:sz="0" w:color="auto"/> <w:insideH w:val="none" w:sz="0" w:color="auto"/> <w:insideV w:val="none" w:sz="0" w:color="auto"/> </w:tblBorders> </w:tblPr> <w:tr> <w:tc> <w:tcPr> <w:tcW w:w="0" w:type="auto"/> <w:shd w:val="clear" w:color="auto" w:fill="$fill"/> </w:tcPr> <w:p> <w:pPr><w:spacing w:before="140" w:after="140"/></w:pPr> <w:r> <w:rPr><w:b/><w:color w:val="FFFFFF"/><w:sz w:val="28"/><w:szCs w:val="28"/><w:rFonts w:ascii="Segoe UI" w:hAnsi="Segoe UI"/></w:rPr> <w:t xml:space="preserve"> $(Esc $title)</w:t> </w:r> </w:p> </w:tc> </w:tr> </w:tbl> "@ } # KPI tile table — colored boxes with large value + small label function local:KpiTable { param([hashtable[]]$kpis) $cells = $kpis | ForEach-Object { $bg = switch ($_.status) { 'Fail' { 'FDE7E9' } 'Warn' { 'FFF4CE' } 'Pass' { 'DFF6DD' } default { 'EFF6FC' } } $fg = switch ($_.status) { 'Fail' { 'A4262C' } 'Warn' { '835B00' } 'Pass' { '107C10' } default { '0078D4' } } @" <w:tc> <w:tcPr> <w:tcW w:w="0" w:type="auto"/> <w:shd w:val="clear" w:color="auto" w:fill="$bg"/> <w:tcBdr> <w:top w:val="single" w:sz="6" w:color="EDEBE9"/> <w:left w:val="single" w:sz="6" w:color="EDEBE9"/> <w:bottom w:val="single" w:sz="6" w:color="EDEBE9"/> <w:right w:val="single" w:sz="6" w:color="EDEBE9"/> </w:tcBdr> </w:tcPr> <w:p> <w:pPr><w:jc w:val="center"/><w:spacing w:before="100" w:after="40"/></w:pPr> <w:r> <w:rPr><w:b/><w:color w:val="$fg"/><w:sz w:val="40"/><w:szCs w:val="40"/><w:rFonts w:ascii="Segoe UI" w:hAnsi="Segoe UI"/></w:rPr> <w:t>$(Esc $_.value)</w:t> </w:r> </w:p> <w:p> <w:pPr><w:jc w:val="center"/><w:spacing w:before="0" w:after="100"/></w:pPr> <w:r> <w:rPr><w:color w:val="605E5C"/><w:sz w:val="18"/><w:szCs w:val="18"/><w:rFonts w:ascii="Segoe UI" w:hAnsi="Segoe UI"/></w:rPr> <w:t>$(Esc $_.label)</w:t> </w:r> </w:p> </w:tc> "@ } @" <w:tbl> <w:tblPr> <w:tblW w:w="0" w:type="auto"/> <w:tblBorders> <w:top w:val="none"/><w:left w:val="none"/> <w:bottom w:val="none"/><w:right w:val="none"/> <w:insideH w:val="none"/><w:insideV w:val="none"/> </w:tblBorders> </w:tblPr> <w:tr>$($cells -join '')</w:tr> </w:tbl> "@ } # Data table with blue header row and alternating body rows function local:DataTable { param([string[]]$headers, [object[]]$rows, [string[]]$props) $hcells = $headers | ForEach-Object { "<w:tc><w:tcPr><w:shd w:val='clear' w:color='auto' w:fill='003A70'/><w:tcMar><w:top w:w='80' w:type='dxa'/><w:left w:w='120' w:type='dxa'/><w:bottom w:w='80' w:type='dxa'/><w:right w:w='120' w:type='dxa'/></w:tcMar></w:tcPr><w:p><w:pPr><w:spacing w:before='60' w:after='60'/></w:pPr><w:r><w:rPr><w:b/><w:color w:val='FFFFFF'/><w:sz w:val='18'/><w:szCs w:val='18'/><w:rFonts w:ascii='Segoe UI' w:hAnsi='Segoe UI'/></w:rPr><w:t>$(Esc $_)</w:t></w:r></w:p></w:tc>" } $hrow = "<w:tr><w:trPr><w:tblHeader/></w:trPr>$($hcells -join '')</w:tr>" $rowIndex = 0 $drows = $rows | ForEach-Object { $obj = $_ $fill = if ($rowIndex % 2 -eq 0) { 'FFFFFF' } else { 'F5F5F5' } $rowIndex++ $dcells = $props | ForEach-Object { $v = $obj.$_; $vStr = if ($null -eq $v) { '' } else { [string]$v } "<w:tc><w:tcPr><w:shd w:val='clear' w:color='auto' w:fill='$fill'/><w:tcMar><w:top w:w='60' w:type='dxa'/><w:left w:w='120' w:type='dxa'/><w:bottom w:w='60' w:type='dxa'/><w:right w:w='120' w:type='dxa'/></w:tcMar></w:tcPr><w:p><w:pPr><w:spacing w:before='40' w:after='40'/></w:pPr><w:r><w:rPr><w:color w:val='323130'/><w:sz w:val='18'/><w:szCs w:val='18'/><w:rFonts w:ascii='Segoe UI' w:hAnsi='Segoe UI'/></w:rPr><w:t xml:space='preserve'>$(Esc $vStr)</w:t></w:r></w:p></w:tc>" } "<w:tr>$($dcells -join '')</w:tr>" } @" <w:tbl> <w:tblPr> <w:tblW w:w="0" w:type="auto"/> <w:tblBorders> <w:top w:val="single" w:sz="4" w:color="EDEBE9"/> <w:left w:val="single" w:sz="4" w:color="EDEBE9"/> <w:bottom w:val="single" w:sz="4" w:color="EDEBE9"/> <w:right w:val="single" w:sz="4" w:color="EDEBE9"/> <w:insideH w:val="single" w:sz="4" w:color="EDEBE9"/> <w:insideV w:val="single" w:sz="4" w:color="EDEBE9"/> </w:tblBorders> </w:tblPr> $hrow $($drows -join '') </w:tbl> "@ } # ── Build document body ─────────────────────────────────────────────────── $body = @() # Cover page $body += Banner 'S2D CARTOGRAPHER' 'Storage Spaces Direct Analysis Report' $body += Spacer $body += Para "Cluster: $cn" -sz 30 -bold $true -spaceBefore 240 -spaceAfter 80 $body += Para "Nodes: $nc" -sz 22 -spaceBefore 40 -spaceAfter 40 $body += Para "Generated: $date" -sz 22 -spaceBefore 40 -spaceAfter 40 if ($Author) { $body += Para "Prepared by: $Author" -sz 22 -spaceBefore 40 -spaceAfter 40 } if ($Company) { $body += Para "Organization: $Company" -sz 22 -spaceBefore 40 -spaceAfter 40 } $ohColor = switch ($oh) { 'Healthy' { '107C10' } 'Warning' { '835B00' } 'Critical' { 'A4262C' } default { '323130' } } $body += Para "Overall Health: $oh" -color $ohColor -sz 26 -bold $true -spaceBefore 160 -spaceAfter 80 $body += PageBreak # Executive Summary $body += SectionHeader 'Executive Summary' $body += Spacer if ($wf) { $reserveKpiStatus = switch ($wf.ReserveStatus) { 'Adequate' { 'Pass' } 'Warning' { 'Warn' } default { 'Fail' } } $ohKpiStatus = switch ($oh) { 'Healthy' { 'Pass' } 'Warning' { 'Warn' } default { 'Fail' } } $body += KpiTable @( @{ label = 'Raw Capacity'; value = "$($wf.RawCapacity.TiB) TiB"; status = 'neutral' } @{ label = 'Usable Capacity'; value = "$($wf.UsableCapacity.TiB) TiB"; status = 'neutral' } @{ label = 'Reserve Status'; value = $wf.ReserveStatus; status = $reserveKpiStatus } @{ label = 'Blended Efficiency'; value = "$($wf.BlendedEfficiencyPercent)%"; status = 'neutral' } @{ label = 'Overall Health'; value = $oh; status = $ohKpiStatus } ) } $body += Spacer $summaryRows = [System.Collections.Generic.List[PSCustomObject]]::new() $summaryRows.Add([PSCustomObject]@{ Metric = 'Cluster Name'; Value = $cn }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Node Count'; Value = $nc }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Overall Health'; Value = $oh }) if ($wf) { $summaryRows.Add([PSCustomObject]@{ Metric = 'Raw Capacity'; Value = "$($wf.RawCapacity.TiB) TiB ($($wf.RawCapacity.TB) TB)" }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Usable Capacity'; Value = "$($wf.UsableCapacity.TiB) TiB ($($wf.UsableCapacity.TB) TB)" }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Reserve Status'; Value = $wf.ReserveStatus }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Resiliency Efficiency'; Value = "$($wf.BlendedEfficiencyPercent)%" }) } if ($pool) { $summaryRows.Add([PSCustomObject]@{ Metric = 'Pool Total'; Value = "$($pool.TotalSize.TiB) TiB ($($pool.TotalSize.TB) TB)" }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Pool Free'; Value = "$($pool.RemainingSize.TiB) TiB ($($pool.RemainingSize.TB) TB)" }) $summaryRows.Add([PSCustomObject]@{ Metric = 'Overcommit Ratio'; Value = "$($pool.OvercommitRatio)x" }) } if ($Author) { $summaryRows.Add([PSCustomObject]@{ Metric = 'Prepared By'; Value = $Author }) } if ($Company) { $summaryRows.Add([PSCustomObject]@{ Metric = 'Organization'; Value = $Company }) } $summaryRows.Add([PSCustomObject]@{ Metric = 'Report Date'; Value = $date }) $body += DataTable -headers @('Metric', 'Value') -rows $summaryRows -props @('Metric', 'Value') $body += PageBreak # Capacity Waterfall $body += SectionHeader 'Capacity Waterfall' $body += Spacer $body += Para 'Theoretical pipeline showing how raw storage is accounted for under S2D best practices. Each stage represents a recommended deduction. No health state is shown — this is pure capacity math.' ` -sz 20 -color '605E5C' -spaceBefore 40 -spaceAfter 120 if ($wf) { $wfRows = $wf.Stages | ForEach-Object { [PSCustomObject]@{ Stage = "Stage $($_.Stage)" Name = $_.Name Deducted = if ($_.Delta -and $_.Delta.TB -gt 0) { "-$($_.Delta.TB) TB" } else { '—' } Remaining = if ($_.Size) { "$($_.Size.TB) TB" } else { '0 TB' } Description = $_.Description } } $body += DataTable -headers @('Stage', 'Name', 'Deducted', 'Remaining', 'Description') ` -rows $wfRows -props @('Stage', 'Name', 'Deducted', 'Remaining', 'Description') $body += Spacer $body += Para "Reserve — Recommended: $($wf.ReserveRecommended.TiB) TiB ($($wf.ReserveRecommended.TB) TB) Actual: $($wf.ReserveActual.TiB) TiB Status: $($wf.ReserveStatus)" ` -sz 20 -bold $true -spaceBefore 60 -spaceAfter 60 } $body += PageBreak # Physical Disk Inventory $body += SectionHeader 'Physical Disk Inventory' $body += Spacer $diskRows = $disks | ForEach-Object { [PSCustomObject]@{ Node = $_.NodeName Model = $_.FriendlyName Type = $_.MediaType Role = $_.Role Size = if ($_.Size) { "$($_.Size.TiB) TiB ($($_.Size.TB) TB)" } else { 'N/A' } Wear = if ($null -ne $_.WearPercentage) { "$($_.WearPercentage)%" } else { 'N/A' } Health = $_.HealthStatus Firmware = $_.FirmwareVersion } } $body += DataTable -headers @('Node', 'Model', 'Type', 'Role', 'Size', 'Wear %', 'Health', 'Firmware') ` -rows $diskRows -props @('Node', 'Model', 'Type', 'Role', 'Size', 'Wear', 'Health', 'Firmware') $body += PageBreak # Volume Map $body += SectionHeader 'Volume Map' $body += Spacer $volRows = $vols | ForEach-Object { $infraMark = if ($_.IsInfrastructureVolume) { ' [Infra]' } else { '' } [PSCustomObject]@{ Name = "$($_.FriendlyName)$infraMark" Resiliency = "$($_.ResiliencySettingName) ($($_.NumberOfDataCopies)x)" Size = if ($_.Size) { "$($_.Size.TiB) TiB" } else { 'N/A' } Footprint = if ($_.FootprintOnPool) { "$($_.FootprintOnPool.TiB) TiB" } else { 'N/A' } Eff = "$($_.EfficiencyPercent)%" Prov = $_.ProvisioningType Headroom = if ($_.ThinGrowthHeadroom) { "$([math]::Round($_.ThinGrowthHeadroom.TiB,2)) TiB" } else { '-' } MaxFP = if ($_.MaxPotentialFootprint) { "$([math]::Round($_.MaxPotentialFootprint.TiB,2)) TiB" } else { '-' } Health = $_.HealthStatus } } $body += DataTable ` -headers @('Volume', 'Resiliency', 'Size', 'Pool Footprint', 'Efficiency', 'Provisioning', 'Growth Headroom', 'Max Potential FP', 'Health') ` -rows $volRows ` -props @('Name', 'Resiliency', 'Size', 'Footprint', 'Eff', 'Prov', 'Headroom', 'MaxFP', 'Health') $body += PageBreak # Health Assessment $body += SectionHeader 'Health Assessment' $body += Spacer $hcRows = $hc | ForEach-Object { [PSCustomObject]@{ Check = $_.CheckName Severity = $_.Severity Status = $_.Status Details = $_.Details } } $body += DataTable -headers @('Check', 'Severity', 'Status', 'Details') ` -rows $hcRows -props @('Check', 'Severity', 'Status', 'Details') # Remediation cards for non-passing checks $nonPass = @($hc | Where-Object { $_.Status -ne 'Pass' }) if ($nonPass.Count -gt 0) { $body += Spacer $body += SectionHeader 'Remediation Actions' -fill '605E5C' $body += Spacer foreach ($check in $nonPass) { $cardFill = switch ($check.Status) { 'Fail' { 'FDE7E9' } 'Warn' { 'FFF4CE' } default { 'F3F2F1' } } $cardFg = switch ($check.Status) { 'Fail' { 'A4262C' } 'Warn' { '835B00' } default { '323130' } } $body += Banner "$($check.CheckName) | $($check.Severity) | $($check.Status)" '' ` -fill $cardFill -textColor $cardFg -sz1 24 $body += Para $check.Details -sz 20 -spaceBefore 60 -spaceAfter 40 if ($check.Remediation) { $body += Para "Remediation: $($check.Remediation)" -sz 20 -bold $true -color '0078D4' -spaceBefore 40 -spaceAfter 100 } } } $body += PageBreak # Appendices $body += SectionHeader 'Appendix A — TiB vs TB' $body += Spacer $body += Para 'Drive manufacturers label storage in decimal terabytes (1 TB = 1,000,000,000,000 bytes). Windows and S2D report capacity in binary tebibytes (1 TiB = 1,099,511,627,776 bytes). This creates an apparent ~9% difference. All data is present — the discrepancy is purely a unit conversion.' ` -sz 20 -color '605E5C' -spaceBefore 60 -spaceAfter 120 $tibRows = @( [PSCustomObject]@{ DriveLabel = '0.96 TB'; Windows = '0.873 TiB'; Diff = '-9.3%' } [PSCustomObject]@{ DriveLabel = '1.92 TB'; Windows = '1.747 TiB'; Diff = '-9.0%' } [PSCustomObject]@{ DriveLabel = '3.84 TB'; Windows = '3.492 TiB'; Diff = '-9.1%' } [PSCustomObject]@{ DriveLabel = '7.68 TB'; Windows = '6.986 TiB'; Diff = '-9.0%' } [PSCustomObject]@{ DriveLabel = '15.36 TB'; Windows = '13.97 TiB'; Diff = '-9.1%' } ) $body += DataTable -headers @('Drive Label', 'Windows Reports', 'Difference') ` -rows $tibRows -props @('DriveLabel', 'Windows', 'Diff') $body += Spacer $body += SectionHeader 'Appendix B — S2D Reserve Space Best Practices' -fill '005A9E' $body += Spacer $reserveText = "Microsoft recommends keeping at least min(NodeCount, 4) x (largest capacity drive size) of unallocated pool " + "space to enable full rebuild after a drive or node failure. " + "For a $nc-node cluster the recommended reserve is " + "$(if ($wf) {"$($wf.ReserveRecommended.TiB) TiB ($($wf.ReserveRecommended.TB) TB)"} else {'N/A'})." $body += Para $reserveText -sz 20 -color '605E5C' -spaceBefore 60 -spaceAfter 60 $bodyXml = $body -join "`n" # ── Open XML package components ─────────────────────────────────────────── $contentTypesXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> <Default Extension="xml" ContentType="application/xml"/> <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/> </Types> '@ $relsXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/> </Relationships> '@ $wordRelsXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/> </Relationships> '@ $stylesXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:docDefaults> <w:rPrDefault> <w:rPr> <w:rFonts w:ascii="Segoe UI" w:hAnsi="Segoe UI" w:cs="Segoe UI"/> <w:sz w:val="22"/> <w:szCs w:val="22"/> <w:color w:val="323130"/> </w:rPr> </w:rPrDefault> </w:docDefaults> <w:style w:type="paragraph" w:default="1" w:styleId="Normal"> <w:name w:val="Normal"/> <w:rPr> <w:rFonts w:ascii="Segoe UI" w:hAnsi="Segoe UI"/> <w:sz w:val="22"/> </w:rPr> </w:style> </w:styles> '@ $documentXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> <w:body> $bodyXml <w:sectPr> <w:pgSz w:w="12240" w:h="15840"/> <w:pgMar w:top="1080" w:right="1080" w:bottom="1080" w:left="1080" w:header="720" w:footer="720" w:gutter="0"/> </w:sectPr> </w:body> </w:document> "@ # ── Assemble DOCX ───────────────────────────────────────────────────────── Add-Type -AssemblyName System.IO.Compression.FileSystem $ms = [System.IO.MemoryStream]::new() $zip = [System.IO.Compression.ZipArchive]::new($ms, [System.IO.Compression.ZipArchiveMode]::Create, $true) function local:AddEntry { param([string]$name, [string]$content) $e = $zip.CreateEntry($name) $sw = [System.IO.StreamWriter]::new($e.Open(), [System.Text.Encoding]::UTF8) $sw.Write($content) $sw.Close() } AddEntry '[Content_Types].xml' $contentTypesXml AddEntry '_rels/.rels' $relsXml AddEntry 'word/_rels/document.xml.rels' $wordRelsXml AddEntry 'word/styles.xml' $stylesXml AddEntry 'word/document.xml' $documentXml $zip.Dispose() [System.IO.File]::WriteAllBytes($OutputPath, $ms.ToArray()) $ms.Dispose() Write-Verbose "Word report written to $OutputPath" $OutputPath } |