Private/PdfWriter.ps1
|
# Dependency-free minimal PDF writer for the shared report block model (see # AnnualReportRender.ps1 for the block shapes). Renders the same ordered list of # layout blocks used by the text/Markdown/Word renderers into a single-file PDF # 1.4 document built entirely by hand — no external module or assembly beyond the # .NET base class library, in the same spirit as the hand-rolled .docx package. # # Text is written with the 14 standard PDF fonts (Helvetica, Helvetica-Bold and # Courier), which require no font embedding. Strings are encoded as single bytes # with ISO-8859-1 (Latin-1); combined with the fonts' WinAnsiEncoding this renders # Swedish characters (å, ä, ö, Å, Ä, Ö) correctly. Tables are drawn in the # monospace Courier font so numeric columns line up exactly using the same padding # logic as the plain-text renderer. function ConvertTo-LedgerReportPdf { [CmdletBinding()] param ( [Parameter(Mandatory)] [object[]]$Block, [Parameter(Mandatory)] [string]$Path ) # --- Page geometry (A4, points) ------------------------------------------- $pageW = 595 $pageH = 842 $marginX = 56 $marginTop = 56 $marginBottom = 56 $yTop = $pageH - $marginTop # Font keys: F1 Helvetica, F2 Helvetica-Bold, F3 Courier (monospace). $escape = { param([string]$Text) if ($null -eq $Text) { return '' } $Text.Replace('\', '\\').Replace('(', '\(').Replace(')', '\)') -replace '[\r\n]', ' ' } # --- 1. Flatten blocks into a list of drawable lines ---------------------- # Each line: @{ Font='F1'; Size=10; Text='...' } or @{ Spacer=$true; Size=6 } $lines = New-Object System.Collections.Generic.List[object] $addLine = { param($font, $size, $text) $lines.Add(@{ Font = $font; Size = $size; Text = [string]$text }) } $addSpacer = { param($size) $lines.Add(@{ Spacer = $true; Size = $size }) } # Wrap long proportional-font text at a conservative character count so it # does not run off the right margin (invoice paragraphs are short). $wrapAt = 95 $addWrapped = { param($font, $size, $text) $text = [string]$text if ($text.Length -le $wrapAt) { & $addLine $font $size $text; return } $words = $text -split ' ' $current = '' foreach ($w in $words) { $candidate = if ($current) { "$current $w" } else { $w } if ($candidate.Length -gt $wrapAt -and $current) { & $addLine $font $size $current $current = $w } else { $current = $candidate } } if ($current) { & $addLine $font $size $current } } foreach ($b in $Block) { switch ($b.Type) { 'Title' { & $addLine 'F2' 18 $b.Text; & $addSpacer 6 } 'Heading' { $size = if ($b.Level -eq 1) { 14 } else { 12 } & $addSpacer 2 & $addLine 'F2' $size $b.Text & $addSpacer 2 } 'Paragraph' { & $addWrapped 'F1' 10 $b.Text } 'Spacer' { & $addSpacer 6 } 'PageBreak' { $lines.Add(@{ PageBreak = $true; Size = 0 }) } 'Signatures' { foreach ($name in $b.Names) { & $addSpacer 24 & $addLine 'F1' 10 ('_' * [Math]::Max(30, ([string]$name).Length)) & $addLine 'F1' 10 $name } & $addSpacer 6 } 'Certificate' { & $addSpacer 2 & $addLine 'F2' 12 $b.Heading & $addSpacer 2 & $addWrapped 'F1' 10 $b.Text & $addSpacer 6 & $addLine 'F1' 10 $b.Place & $addSpacer 24 & $addLine 'F1' 10 ('_' * [Math]::Max(30, ([string]$b.Signer).Length)) & $addLine 'F1' 10 $b.Signer & $addSpacer 6 } 'Table' { # Reuse the plain-text column padding so Courier lines align. $cols = $b.Header.Count $widths = New-Object 'int[]' $cols for ($c = 0; $c -lt $cols; $c++) { $widths[$c] = ([string]$b.Header[$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) } } $buildLine = { 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 } $headerLine = & $buildLine $b.Header & $addLine 'F3' 9 $headerLine & $addLine 'F3' 9 ('-' * $headerLine.Length) foreach ($row in $b.Rows) { & $addLine 'F3' 9 (& $buildLine $row) } & $addSpacer 4 } } } # --- 2. Paginate: assign each line a page and y position ------------------- $pages = New-Object System.Collections.Generic.List[object] $currentPage = New-Object System.Collections.Generic.List[object] $y = $yTop foreach ($ln in $lines) { if ($ln.PageBreak) { if ($currentPage.Count -gt 0) { $pages.Add($currentPage) $currentPage = New-Object System.Collections.Generic.List[object] $y = $yTop } continue } $lineHeight = [Math]::Round($ln.Size * 1.4) if ($lineHeight -lt 8) { $lineHeight = 8 } if (($y - $lineHeight) -lt $marginBottom) { if ($currentPage.Count -gt 0) { $pages.Add($currentPage) } $currentPage = New-Object System.Collections.Generic.List[object] $y = $yTop } $y = $y - $lineHeight if (-not $ln.Spacer) { $currentPage.Add(@{ Font = $ln.Font; Size = $ln.Size; Text = $ln.Text; X = $marginX; Y = $y }) } } if ($currentPage.Count -gt 0) { $pages.Add($currentPage) } if ($pages.Count -eq 0) { $pages.Add((New-Object System.Collections.Generic.List[object])) } # --- 3. Build content stream text for each page --------------------------- $pageStreams = foreach ($page in $pages) { $sb = New-Object System.Text.StringBuilder foreach ($item in $page) { $t = & $escape $item.Text [void]$sb.Append("BT /$($item.Font) $($item.Size) Tf $($item.X) $($item.Y) Td ($t) Tj ET`n") } $sb.ToString() } $pageStreams = @($pageStreams) # --- 4. Assemble PDF objects with a byte-accurate xref -------------------- $enc = [System.Text.Encoding]::GetEncoding(28591) # ISO-8859-1 (Latin-1) $bytes = New-Object System.Collections.Generic.List[byte] $offsets = @{} $append = { param([string]$s) $b = $enc.GetBytes($s); $bytes.AddRange($b) } $startObj = { param([int]$n) $offsets[$n] = $bytes.Count; & $append "$n 0 obj`n" } $endObj = { & $append "endobj`n" } # Object plan: # 1 Catalog, 2 Pages, 3 Helvetica, 4 Helvetica-Bold, 5 Courier # per page p (0-based): page obj = 6 + p*2, content obj = 7 + p*2 $pageCount = $pageStreams.Count $pageObjNums = 0..($pageCount - 1) | ForEach-Object { 6 + $_ * 2 } $totalObjects = 5 + $pageCount * 2 & $append "%PDF-1.4`n" & $append "%$([char]0xE2)$([char]0xE3)$([char]0xCF)$([char]0xD3)`n" # 1: Catalog & $startObj 1 & $append "<< /Type /Catalog /Pages 2 0 R >>`n" & $endObj # 2: Pages & $startObj 2 $kids = ($pageObjNums | ForEach-Object { "$_ 0 R" }) -join ' ' & $append "<< /Type /Pages /Kids [$kids] /Count $pageCount >>`n" & $endObj # 3-5: Fonts $fonts = @( @{ N = 3; Base = 'Helvetica' } @{ N = 4; Base = 'Helvetica-Bold' } @{ N = 5; Base = 'Courier' } ) foreach ($f in $fonts) { & $startObj $f.N & $append "<< /Type /Font /Subtype /Type1 /BaseFont /$($f.Base) /Encoding /WinAnsiEncoding >>`n" & $endObj } # Page + content objects for ($p = 0; $p -lt $pageCount; $p++) { $pageObj = 6 + $p * 2 $contentObj = 7 + $p * 2 & $startObj $pageObj & $append ("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 $pageW $pageH] " + "/Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> " + "/Contents $contentObj 0 R >>`n") & $endObj $stream = $pageStreams[$p] $streamLen = $enc.GetBytes($stream).Length & $startObj $contentObj & $append "<< /Length $streamLen >>`nstream`n" & $append $stream & $append "endstream`n" & $endObj } # xref $xrefOffset = $bytes.Count & $append "xref`n" & $append "0 $($totalObjects + 1)`n" & $append "0000000000 65535 f `n" for ($n = 1; $n -le $totalObjects; $n++) { $off = ([string]$offsets[$n]).PadLeft(10, '0') & $append "$off 00000 n `n" } # trailer & $append "trailer`n" & $append "<< /Size $($totalObjects + 1) /Root 1 0 R >>`n" & $append "startxref`n" & $append "$xrefOffset`n" & $append "%%EOF`n" # --- Write bytes to disk --------------------------------------------------- $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) if (Test-Path $fullPath) { Remove-Item $fullPath -Force } [System.IO.File]::WriteAllBytes($fullPath, $bytes.ToArray()) } |