Private/AnnualReportRender.ps1

<#
    Private helpers: render an annual report expressed as an ordered list of layout
    "blocks" into plain text, Markdown or a Word (.docx) document. Keeping a single
    block model means every output format shows the same sections, headings and
    tables. A block is a hashtable with a Type key:

      @{ Type = 'Title'; Text = '...'; Cover = $true }
      @{ Type = 'Heading'; Level = 1|2; Text = '...' }
      @{ Type = 'Paragraph'; Text = '...'; Cover = $true }
      @{ Type = 'Table'; Header = @('Post','2024','2023'); KeepNext = $true (optional);
                             Align = @('left','right','right');
                             Rows = @( @('...','..','..'), ... );
                             RowStyles = @('Normal'|'Sum'|'Section', ...) }
      @{ Type = 'Signatures'; Names = @('...', ...) }
      @{ Type = 'Certificate'; Heading = '...'; Text = '...'; Place = '...'; Signer = '...' }
      @{ Type = 'PageBreak' }
      @{ Type = 'Spacer' }

    Cover marks the title-page blocks. RowStyles is optional and parallel to Rows:
    'Sum' rows are summary lines (bold with a rule above) and 'Section' rows are
    captions without amounts.
#>


function Get-LedgerReportRowStyle {
    param ($Block, [int]$Index)
    if ($Block.RowStyles -and $Index -lt $Block.RowStyles.Count -and $Block.RowStyles[$Index]) {
        return [string]$Block.RowStyles[$Index]
    }
    'Normal'
}

function ConvertTo-LedgerReportText {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object[]]$Block
    )

    $nl = "`r`n"
    $sb = New-Object System.Text.StringBuilder
    $append = { param($line) [void]$sb.Append($line); [void]$sb.Append($nl) }

    foreach ($b in $Block) {
        switch ($b.Type) {
            'Title' {
                & $append $b.Text
                & $append ('=' * [Math]::Max(4, $b.Text.Length))
                & $append ''
            }
            'Heading' {
                & $append $b.Text
                if ($b.Level -eq 1) { & $append ('-' * [Math]::Max(4, $b.Text.Length)) }
                & $append ''
            }
            'Paragraph' {
                & $append $b.Text
                & $append ''
            }
            'Spacer' { & $append '' }
            'PageBreak' { & $append '' }
            'Signatures' {
                foreach ($name in $b.Names) {
                    & $append ''
                    & $append ('_' * [Math]::Max(30, $name.Length))
                    & $append $name
                }
                & $append ''
            }
            'Certificate' {
                & $append $b.Heading
                & $append ''
                & $append $b.Text
                & $append ''
                & $append $b.Place
                & $append ''
                & $append ''
                & $append ('_' * [Math]::Max(30, ([string]$b.Signer).Length))
                & $append $b.Signer
                & $append ''
            }
            'Table' {
                $cols = $b.Header.Count
                $headerCells = @($b.Header | ForEach-Object { ([string]$_) -replace "`n", ' ' })
                $widths = New-Object 'int[]' $cols
                for ($c = 0; $c -lt $cols; $c++) { $widths[$c] = $headerCells[$c].Length }
                foreach ($row in $b.Rows) {
                    for ($c = 0; $c -lt $cols; $c++) {
                        $len = ([string]$row[$c]).Length
                        if ($len -gt $widths[$c]) { $widths[$c] = $len }
                    }
                }
                $formatCell = {
                    param($text, $width, $align)
                    $text = [string]$text
                    if ($align -eq 'right') { $text.PadLeft($width) } else { $text.PadRight($width) }
                }
                $formatLine = {
                    param($cells)
                    $line = ''
                    for ($c = 0; $c -lt $cols; $c++) {
                        $align = if ($b.Align) { $b.Align[$c] } else { 'left' }
                        $line += (& $formatCell $cells[$c] $widths[$c] $align)
                        if ($c -lt $cols - 1) { $line += ' ' }
                    }
                    $line.TrimEnd()
                }
                & $append (& $formatLine $headerCells)
                for ($i = 0; $i -lt $b.Rows.Count; $i++) {
                    $style = Get-LedgerReportRowStyle -Block $b -Index $i
                    if ($style -eq 'Section' -and $i -gt 0) { & $append '' }
                    & $append (& $formatLine $b.Rows[$i])
                }
                & $append ''
            }
        }
    }

    $sb.ToString()
}

function ConvertTo-LedgerReportMarkdown {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object[]]$Block
    )

    $nl = "`r`n"
    $sb = New-Object System.Text.StringBuilder
    $append = { param($line) [void]$sb.Append($line); [void]$sb.Append($nl) }

    foreach ($b in $Block) {
        switch ($b.Type) {
            'Title' { & $append "# $($b.Text)"; & $append '' }
            'Heading' {
                $prefix = if ($b.Level -eq 1) { '## ' } else { '### ' }
                & $append "$prefix$($b.Text)"; & $append ''
            }
            'Paragraph' { & $append $b.Text; & $append '' }
            'Spacer' { & $append '' }
            'PageBreak' { }
            'Signatures' {
                foreach ($name in $b.Names) {
                    & $append '_________________________'
                    & $append ''
                    & $append $name
                    & $append ''
                }
            }
            'Certificate' {
                & $append "### $($b.Heading)"; & $append ''
                & $append $b.Text; & $append ''
                & $append $b.Place; & $append ''
                & $append '_________________________'; & $append ''
                & $append $b.Signer; & $append ''
            }
            'Table' {
                $cols = $b.Header.Count
                & $append ('| ' + (($b.Header | ForEach-Object { ([string]$_) -replace "`n", ' ' }) -join ' | ') + ' |')
                $divider = @()
                for ($c = 0; $c -lt $cols; $c++) {
                    $align = if ($b.Align) { $b.Align[$c] } else { 'left' }
                    $divider += if ($align -eq 'right') { '---:' } else { '---' }
                }
                & $append ('| ' + ($divider -join ' | ') + ' |')
                for ($i = 0; $i -lt $b.Rows.Count; $i++) {
                    $style = Get-LedgerReportRowStyle -Block $b -Index $i
                    $cells = @()
                    foreach ($cell in $b.Rows[$i]) {
                        $text = [string]$cell
                        if ($style -ne 'Normal' -and $text -ne '') { $text = "**$text**" }
                        $cells += $text
                    }
                    & $append ('| ' + ($cells -join ' | ') + ' |')
                }
                & $append ''
            }
        }
    }

    $sb.ToString()
}

function ConvertTo-LedgerReportDocx {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object[]]$Block,

        [Parameter(Mandatory)]
        [string]$Path
    )

    # Text area width of an A4 page with 25 mm margins, in twentieths of a point.
    $textWidth = 9072

    function ConvertTo-LedgerXmlText {
        param ([string]$Text)
        if ($null -eq $Text) { return '' }
        $Text.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;').Replace('"', '&quot;')
    }

    function New-Run {
        param ([string]$Text, [switch]$Bold)
        $rPr = if ($Bold) { '<w:rPr><w:b/><w:bCs/></w:rPr>' } else { '' }
        # A newline in the text becomes a line break (used for period column headers).
        $parts = foreach ($line in ($Text -split "`n")) { "<w:t xml:space=""preserve"">$(ConvertTo-LedgerXmlText $line)</w:t>" }
        "<w:r>$rPr$($parts -join '<w:br/>')</w:r>"
    }

    function New-Paragraph {
        param ([string]$Text, [string]$Style, [switch]$Bold, [string]$Align, [string]$ExtraPPr = '')
        $pPr = ''
        if ($Style) { $pPr += "<w:pStyle w:val=""$Style""/>" }
        $pPr += $ExtraPPr
        if ($Align -eq 'right') { $pPr += '<w:jc w:val="right"/>' }
        elseif ($Align -eq 'center') { $pPr += '<w:jc w:val="center"/>' }
        $pPrXml = if ($pPr) { "<w:pPr>$pPr</w:pPr>" } else { '' }
        $run = if ($null -ne $Text -and $Text -ne '') { New-Run -Text $Text -Bold:$Bold } else { '' }
        "<w:p>$pPrXml$run</w:p>"
    }

    # Column widths: wide label column, a narrow Not column and equal amount columns.
    function Get-ColumnWidths {
        param ($Header)
        $cols = $Header.Count
        $hasNote = $cols -gt 2 -and [string]$Header[1] -eq 'Not'
        $amountCols = if ($hasNote) { $cols - 2 } else { $cols - 1 }
        $amountWidth = if ($cols -ge 5) { 1450 } else { 1600 }
        $noteWidth = if ($hasNote) { 600 } else { 0 }
        $tableWidth = if ($cols -le 2) { 6800 } else { $textWidth }
        $labelWidth = $tableWidth - $noteWidth - ($amountCols * $amountWidth)
        $widths = @($labelWidth)
        if ($hasNote) { $widths += $noteWidth }
        for ($i = 0; $i -lt $amountCols; $i++) { $widths += $amountWidth }
        , $widths
    }

    $border = { param($edge, $size) "<w:$edge w:val=""single"" w:sz=""$size"" w:space=""0"" w:color=""000000""/>" }

    $body = New-Object System.Text.StringBuilder
    $isFirstCover = $true
    foreach ($b in $Block) {
        switch ($b.Type) {
            'Title' {
                if ($b.Cover) {
                    [void]$body.Append((New-Paragraph -Text $b.Text -Style 'Title' -Align 'center' -ExtraPPr '<w:spacing w:before="2400" w:after="480"/>'))
                }
                else {
                    [void]$body.Append((New-Paragraph -Text $b.Text -Style 'Title'))
                }
            }
            'Heading' {
                $style = if ($b.Level -eq 1) { 'Heading1' } else { 'Heading2' }
                [void]$body.Append((New-Paragraph -Text $b.Text -Style $style))
            }
            'Paragraph' {
                if ($b.Cover) {
                    $style = if ($isFirstCover) { 'CoverName' } else { 'CoverText' }
                    $isFirstCover = $false
                    [void]$body.Append((New-Paragraph -Text $b.Text -Style $style -Align 'center'))
                }
                elseif ($b.KeepNext) {
                    [void]$body.Append((New-Paragraph -Text $b.Text -ExtraPPr '<w:keepNext/>'))
                }
                else {
                    [void]$body.Append((New-Paragraph -Text $b.Text))
                }
            }
            'Spacer' { [void]$body.Append('<w:p/>') }
            'PageBreak' { [void]$body.Append('<w:p><w:r><w:br w:type="page"/></w:r></w:p>') }
            'Certificate' {
                # Lower part of the cover page, left-aligned like a form to fill in.
                [void]$body.Append((New-Paragraph -Text $b.Heading -Style 'Heading2' -ExtraPPr '<w:keepNext/><w:spacing w:before="3600" w:after="120"/>'))
                [void]$body.Append((New-Paragraph -Text $b.Text -ExtraPPr '<w:keepNext/>'))
                [void]$body.Append((New-Paragraph -Text $b.Place -ExtraPPr '<w:keepNext/><w:spacing w:before="240"/>'))
                [void]$body.Append('<w:p><w:pPr><w:keepNext/><w:spacing w:before="720" w:after="0"/></w:pPr></w:p>')
                [void]$body.Append((New-Paragraph -Text $b.Signer -ExtraPPr ("<w:pBdr>$(& $border 'top' 6)</w:pBdr><w:ind w:right=""$([int]($textWidth / 2 + 720))""/>")))
            }
            'Signatures' {
                # Two signatures per row in a borderless table with a line to sign on.
                $names = @($b.Names)
                $cellWidth = [int]($textWidth / 2)
                $tbl = New-Object System.Text.StringBuilder
                [void]$tbl.Append("<w:tbl><w:tblPr><w:tblW w:w=""$textWidth"" w:type=""dxa""/><w:tblLayout w:type=""fixed""/></w:tblPr>")
                [void]$tbl.Append("<w:tblGrid><w:gridCol w:w=""$cellWidth""/><w:gridCol w:w=""$cellWidth""/></w:tblGrid>")
                for ($i = 0; $i -lt $names.Count; $i += 2) {
                    [void]$tbl.Append('<w:tr><w:trPr><w:cantSplit/></w:trPr>')
                    foreach ($name in @($names[$i], $(if ($i + 1 -lt $names.Count) { $names[$i + 1] } else { $null }))) {
                        [void]$tbl.Append("<w:tc><w:tcPr><w:tcW w:w=""$cellWidth"" w:type=""dxa""/></w:tcPr>")
                        if ($name) {
                            [void]$tbl.Append('<w:p><w:pPr><w:keepNext/><w:spacing w:before="0" w:after="0" w:line="720" w:lineRule="exact"/></w:pPr></w:p>')
                            [void]$tbl.Append((New-Paragraph -Text $name -ExtraPPr ("<w:pBdr>$(& $border 'top' 6)</w:pBdr><w:ind w:right=""720""/>")))
                        }
                        else {
                            [void]$tbl.Append('<w:p/>')
                        }
                        [void]$tbl.Append('</w:tc>')
                    }
                    [void]$tbl.Append('</w:tr>')
                }
                # Word needs a paragraph after a table; keep it tiny so it cannot spill onto a new page.
                [void]$tbl.Append('</w:tbl><w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="20" w:lineRule="exact"/></w:pPr></w:p>')
                [void]$body.Append($tbl.ToString())
            }
            'Table' {
                $cols = $b.Header.Count
                $widths = Get-ColumnWidths -Header $b.Header
                $tableWidth = ($widths | Measure-Object -Sum).Sum
                $tbl = New-Object System.Text.StringBuilder
                [void]$tbl.Append("<w:tbl><w:tblPr><w:tblW w:w=""$tableWidth"" w:type=""dxa""/><w:tblLayout w:type=""fixed""/>")
                [void]$tbl.Append('<w:tblCellMar><w:left w:w="57" w:type="dxa"/><w:right w:w="57" w:type="dxa"/></w:tblCellMar></w:tblPr>')
                [void]$tbl.Append('<w:tblGrid>')
                foreach ($w in $widths) { [void]$tbl.Append("<w:gridCol w:w=""$w""/>") }
                [void]$tbl.Append('</w:tblGrid>')

                $makeRow = {
                    param($cells, [string]$RowStyle, [switch]$HeaderRow, [switch]$KeepNext)
                    $trPr = '<w:cantSplit/>'
                    if ($HeaderRow) { $trPr += '<w:tblHeader/>' }
                    $tr = "<w:tr><w:trPr>$trPr</w:trPr>"
                    for ($c = 0; $c -lt $cols; $c++) {
                        $align = if ($b.Align) { $b.Align[$c] } else { 'left' }
                        $tcBorders = ''
                        if ($HeaderRow) { $tcBorders = "<w:tcBorders>$(& $border 'bottom' 6)</w:tcBorders>" }
                        elseif ($RowStyle -eq 'Sum' -and $c -gt 0 -and [string]$b.Header[$c] -ne 'Not') { $tcBorders = "<w:tcBorders>$(& $border 'top' 4)</w:tcBorders>" }
                        $pPr = '<w:spacing w:before="20" w:after="20"/>'
                        if ($RowStyle -eq 'Section') { $pPr = '<w:spacing w:before="160" w:after="20"/>' }
                        if ($KeepNext) { $pPr = '<w:keepNext/>' + $pPr }
                        $bold = $HeaderRow -or $RowStyle -eq 'Sum' -or $RowStyle -eq 'Section'
                        $para = New-Paragraph -Text ([string]$cells[$c]) -Style 'TableText' -Bold:$bold -Align $align -ExtraPPr $pPr
                        $tr += "<w:tc><w:tcPr><w:tcW w:w=""$($widths[$c])"" w:type=""dxa""/>$tcBorders<w:vAlign w:val=""bottom""/></w:tcPr>$para</w:tc>"
                    }
                    $tr + '</w:tr>'
                }

                [void]$tbl.Append((& $makeRow $b.Header -RowStyle 'Normal' -HeaderRow -KeepNext))
                for ($i = 0; $i -lt $b.Rows.Count; $i++) {
                    $style = Get-LedgerReportRowStyle -Block $b -Index $i
                    # Keep a table on one page where possible.
                    # KeepNext on the block also keeps the last row with what follows.
                    $keep = $b.KeepNext -or $i -lt $b.Rows.Count - 1
                    [void]$tbl.Append((& $makeRow $b.Rows[$i] -RowStyle $style -KeepNext:$keep))
                }
                [void]$tbl.Append('</w:tbl>')
                # A table must be followed by a paragraph in Word; a 6 pt one gives just enough air.
                $spacerKeep = if ($b.KeepNext) { '<w:keepNext/>' } else { '' }
                [void]$tbl.Append("<w:p><w:pPr>$spacerKeep<w:spacing w:before=""0"" w:after=""0"" w:line=""120"" w:lineRule=""exact""/></w:pPr></w:p>")
                [void]$body.Append($tbl.ToString())
            }
        }
    }

    # The footer repeats the company line from the cover page with page numbers.
    $footerLabel = ($Block | Where-Object { $_.Type -eq 'Paragraph' -and $_.Cover } | Select-Object -First 1).Text
    $fld = { param($instr) "<w:r><w:fldChar w:fldCharType=""begin""/></w:r><w:r><w:instrText xml:space=""preserve""> $instr </w:instrText></w:r><w:r><w:fldChar w:fldCharType=""separate""/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType=""end""/></w:r>" }
    $footerXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
        '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
        "<w:p><w:pPr><w:pStyle w:val=""Footer""/><w:pBdr>$(& $border 'top' 4)</w:pBdr><w:tabs><w:tab w:val=""right"" w:pos=""$textWidth""/></w:tabs></w:pPr>" +
        "<w:r><w:t xml:space=""preserve"">$(ConvertTo-LedgerXmlText $footerLabel)</w:t></w:r><w:r><w:tab/><w:t xml:space=""preserve"">Sida </w:t></w:r>" +
        (& $fld 'PAGE') + '<w:r><w:t xml:space="preserve"> (</w:t></w:r>' + (& $fld 'NUMPAGES') + '<w:r><w:t>)</w:t></w:r></w:p></w:ftr>'

    $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>' + $body.ToString() +
        '<w:sectPr><w:footerReference w:type="default" r:id="rId2"/><w:pgSz w:w="11906" w:h="16838"/>' +
        '<w:pgMar w:top="1417" w:right="1417" w:bottom="1417" w:left="1417" w:header="708" w:footer="708" w:gutter="0"/>' +
        '<w:titlePg/></w:sectPr></w:body></w:document>'

    $font = '<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/>'
    $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>$font<w:sz w:val=""21""/><w:szCs w:val=""21""/><w:lang w:val=""sv-SE""/></w:rPr></w:rPrDefault>" +
        '<w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="264" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>' +
        '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>' +
        '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>' +
        '<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:color w:val="1F3864"/><w:sz w:val="56"/><w:szCs w:val="56"/></w:rPr></w:style>' +
        '<w:style w:type="paragraph" w:styleId="CoverName"><w:name w:val="Cover Name"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
        '<w:pPr><w:spacing w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr></w:style>' +
        '<w:style w:type="paragraph" w:styleId="CoverText"><w:name w:val="Cover Text"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
        '<w:rPr><w:sz w:val="26"/><w:szCs w:val="26"/></w:rPr></w:style>' +
        '<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>' +
        '<w:pPr><w:keepNext/><w:spacing w:before="240" w:after="160"/><w:outlineLvl w:val="0"/></w:pPr>' +
        '<w:rPr><w:b/><w:color w:val="1F3864"/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr></w:style>' +
        '<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>' +
        '<w:pPr><w:keepNext/><w:spacing w:before="240" w:after="80"/><w:outlineLvl w:val="1"/></w:pPr>' +
        '<w:rPr><w:b/><w:sz w:val="23"/><w:szCs w:val="23"/></w:rPr></w:style>' +
        '<w:style w:type="paragraph" w:styleId="TableText"><w:name w:val="Table Text"/><w:basedOn w:val="Normal"/>' +
        '<w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/></w:rPr></w:style>' +
        '<w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/>' +
        '<w:pPr><w:spacing w:after="0"/></w:pPr><w:rPr><w:color w:val="595959"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr></w:style>' +
        '</w:styles>'

    $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"/>' +
        '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+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>'

    $documentRelsXml = '<?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"/>' +
        '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>' +
        '</Relationships>'

    Add-Type -AssemblyName System.IO.Compression | Out-Null
    Add-Type -AssemblyName System.IO.Compression.FileSystem | Out-Null

    $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    if (Test-Path $fullPath) { Remove-Item $fullPath -Force }

    $utf8 = New-Object System.Text.UTF8Encoding($false)
    $stream = [System.IO.File]::Open($fullPath, [System.IO.FileMode]::Create)
    try {
        $archive = New-Object System.IO.Compression.ZipArchive($stream, [System.IO.Compression.ZipArchiveMode]::Create)
        try {
            $writeEntry = {
                param($name, $content)
                $entry = $archive.CreateEntry($name)
                $es = $entry.Open()
                try {
                    $bytes = $utf8.GetBytes($content)
                    $es.Write($bytes, 0, $bytes.Length)
                }
                finally { $es.Dispose() }
            }
            & $writeEntry '[Content_Types].xml' $contentTypesXml
            & $writeEntry '_rels/.rels' $relsXml
            & $writeEntry 'word/document.xml' $documentXml
            & $writeEntry 'word/_rels/document.xml.rels' $documentRelsXml
            & $writeEntry 'word/styles.xml' $stylesXml
            & $writeEntry 'word/footer1.xml' $footerXml
        }
        finally { $archive.Dispose() }
    }
    finally { $stream.Dispose() }
}