Public/Add-VellumPdfPageList.ps1
|
function Add-VellumPdfPageList { <# .SYNOPSIS Draws a bulleted or numbered list at x/y on a page of a page-based document, tagged as a real list. .DESCRIPTION The page-document counterpart of Add-VellumPdfList: each item is a marker (a bullet, or 1. 2. 3. with -Numbered) followed by text that wraps inside -Width and hangs under itself. -X is the left edge and -Y the top of the list in points from the page's bottom-left. A list that does not fit above the bottom of the page is an error. An item is text, or a hashtable (or object) with Text and optionally Children (one more level of items). An item without Text is an error; a missing or $null Children means no children: -Item 'First', @{ Text = 'Second'; Children = 'Second A', 'Second B' }, 'Third' In a tagged page document (PDF/UA-1, PDF/A-2a, -Tagged) the list is an L element with LI items, each holding a Lbl (the marker) and an LBody (the text, plus the nested L of an item that has children). In an untagged document it is only drawing. Items become text with a culture and an optional format. A string item is drawn as given unless it consists only of a number; any other value (a number, a date, ...) is converted with -Culture (the document's -Culture, invariant unless set) and -Format (a .NET format string such as 'N2' or 'yyyy-MM-dd'); an item hashtable may carry its own Format key, and Parse = $false to draw its string as given. Numbers in strings are always recognised (switch it off with -NoNumberParsing): a string item that is only a number is read as [decimal], keeping its decimals, and then drawn like any other number, so with the invariant default '1.444,50' becomes 1444.50, '1,976.08' becomes 1976.08 and '55433.34' stays 55433.34. The separators are detected from the text: 1. With both '.' and ',' the LAST one is the decimal mark (1.444,50 and 1,976.08). 2. A separator that appears more than once is grouping (1.234.567). 3. A separator that appears once is a decimal mark (55433.34, 66443,85), except that exactly three digits after it with at most three digits before it is ambiguous (1,234 and 1.234; with more than three digits before it, as in 12345,678, it is a decimal mark). 4. Grouping must be regular: 1-3 digits, then groups of exactly 3 (1.2.3 and 10.0.0.1 stay text). 5. Not a number, so left as is: a leading zero in a multi-digit integer part (007, 01234), spaces, currency symbols, '%', exponents, an empty string. 6. An ambiguous number takes the convention of the other items of the same call; without such evidence it is left unchanged and ONE warning per call names the values. -InputCulture switches detection off: every string is read strictly with that culture's separators. Use a base-14 font by name (-Font) or an embedded TrueType font (-FontHandle from Register-VellumPdfFont); PDF/A and PDF/UA documents embed base-14 text automatically. .PARAMETER Document A document from New-VellumPdfPageDocument. .PARAMETER Item The items: text, or hashtables with Text and Children. .PARAMETER Page The 1-based page number. Default 1. .PARAMETER X Left edge of the list in points. .PARAMETER Y Top of the list in points from the bottom of the page. .PARAMETER Width Total width of the list including the marker column. .PARAMETER Numbered Number the items 1. 2. 3. instead of using a bullet; children are numbered a. b. c. .PARAMETER Marker The bullet character (default a bullet; a hyphen when the font has no bullet glyph). .PARAMETER FontSize Size in points (default 11). .PARAMETER Font A base-14 font name (default Helvetica). .PARAMETER FontHandle An embedded TrueType font from Register-VellumPdfFont. Overrides -Font. .PARAMETER Color Text colour: RGB triple in 0..1, hex or name. Default black. .PARAMETER Leading Distance between baselines in points. Default 1.2 times -FontSize. .PARAMETER ItemSpacing Extra space after every item, in points (default 3). .PARAMETER Indent Width of the marker column in points (default 1.8 times -FontSize). Children are indented by one more. .PARAMETER Language BCP 47 language tag of the list text, written on the L element. .PARAMETER Format A .NET format string for items that are not text ('N2', 'yyyy-MM-dd'), applied with -Culture; also to numbers read from strings. An item's own Format key wins. A format the value's type rejects is an error naming the item. .PARAMETER Culture The culture used to turn numbers and dates into text: a name ('de-DE') or a [cultureinfo] (-Culture (Get-Culture) matches the console). Default: the document's -Culture, which is the invariant culture unless New-VellumPdfDocument/New-VellumPdfPageDocument set one. An unknown name is an error. .PARAMETER InputCulture Read number strings strictly with this culture (name or [cultureinfo]) instead of detecting the separators. Default: the document's -InputCulture, else detection. Pass $null to switch the document's -InputCulture off for this call. .PARAMETER NoNumberParsing Draw every string exactly as given instead of reading numbers from it. A single item can do the same with the key Parse = $false. .PARAMETER PassThru Return an object with Document, Height, Bottom (the y of the list's lower edge) and ItemCount instead of the document. .EXAMPLE $doc | Add-VellumPdfPageList -X 72 -Y 700 -Width 300 -Item 'Read the brief', 'Draft the plan', 'Review with the team' .EXAMPLE $doc | Add-VellumPdfPageList -X 72 -Y 600 -Width 300 -Numbered -Item 'Prepare', @{ Text = 'Run'; Children = 'Dry run', 'Real run' }, 'Report' .EXAMPLE $doc | Add-VellumPdfPageList -X 72 -Y 500 -Width 300 -Item 1234.5, '1.444,5', 99 -Format 'N2' -Culture de-DE .OUTPUTS VellumPdf.Document.PdfDocument (the same instance, for chaining), or with -PassThru a summary object. #> [CmdletBinding()] [OutputType([VellumPdf.Document.PdfDocument], [pscustomobject])] param( [Parameter(Mandatory, ValueFromPipeline)] [VellumPdf.Document.PdfDocument]$Document, [Parameter(Mandatory, Position = 0)] [ValidateCount(1, 100000)] [AllowEmptyString()] [object[]]$Item, [ValidateRange(1, 1000000)] [int]$Page = 1, [Parameter(Mandatory)] [double]$X, [Parameter(Mandatory)] [double]$Y, [Parameter(Mandatory)] [ValidateRange(1.0, 1000000.0)] [double]$Width, [switch]$Numbered, [ValidateNotNullOrEmpty()] [string]$Marker = [string][char]0x2022, [ValidateRange(1.0, 1000.0)] [double]$FontSize = 11, [ValidateSet('Courier', 'CourierBold', 'CourierBoldOblique', 'CourierOblique', 'Helvetica', 'HelveticaBold', 'HelveticaBoldOblique', 'HelveticaOblique', 'Symbol', 'TimesBold', 'TimesBoldItalic', 'TimesItalic', 'TimesRoman', 'ZapfDingbats')] [string]$Font = 'Helvetica', [VellumPdf.Fonts.EmbeddedFontHandle]$FontHandle, [object]$Color, [ValidateRange(0.1, 10000.0)] [double]$Leading, [ValidateRange(0.0, 1000.0)] [double]$ItemSpacing = 3, [ValidateRange(1.0, 1000.0)] [double]$Indent, [ValidateNotNullOrEmpty()] [string]$Language, # .NET format string for items that are not text ('N2', 'yyyy-MM-dd'); an item hashtable may carry its own Format key. [AllowNull()] [AllowEmptyString()] [string]$Format, # Culture used to turn dates and numbers into text; a name or a [cultureinfo]. # Default: the document's -Culture (invariant unless set). [object]$Culture, # Culture used to read numbers given as strings (strict, no detection); $null switches the # document's -InputCulture off for this call. Default: the document's -InputCulture, else detection. [AllowNull()] [object]$InputCulture, # Draw strings exactly as given instead of reading numbers from them. [switch]$NoNumberParsing, [switch]$PassThru ) process { try { Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfPageList' $Page = Resolve-VellumPdfPageNumber -Document $Document -Page $(if ($PSBoundParameters.ContainsKey('Page')) { $PSBoundParameters['Page'] } else { 1 }) -Bound $PSBoundParameters.ContainsKey('Page') -CommandName 'Add-VellumPdfPageList' [void](Get-VellumPdfPage -Document $Document -Page $Page -CommandName 'Add-VellumPdfPageList') Assert-VellumPdfLanguageTag -Value $Language -CommandName 'Add-VellumPdfPageList' $cultures = Resolve-VellumPdfCulture -Document $Document -Culture $Culture -CultureBound:$PSBoundParameters.ContainsKey('Culture') ` -InputCulture $InputCulture -InputCultureBound:$PSBoundParameters.ContainsKey('InputCulture') -CommandName 'Add-VellumPdfPageList' if (-not $FontHandle) { Assert-VellumPdfMeasurableFont -Font $Font -CommandName 'Add-VellumPdfPageList' -Reason 'it wraps item text' } Assert-VellumPdfCoordinate $X 'X' 'Add-VellumPdfPageList' Assert-VellumPdfCoordinate $Y 'Y' 'Add-VellumPdfPageList' Assert-VellumPdfCoordinate $Width 'Width' 'Add-VellumPdfPageList' $bound = $PSBoundParameters if (-not $bound.ContainsKey('Leading')) { $Leading = 1.2 * $FontSize } $markerIndent = if ($bound.ContainsKey('Indent')) { $Indent } else { 1.8 * $FontSize } # --- items ------------------------------------------------------------------------------------ # An item is text, or a hashtable / object with Text and optionally Children. $isRecord = { param($x) $x -is [System.Collections.IDictionary] -or $x -is [pscustomobject] } # The raw value plus the item's own Format / Parse keys: it becomes text (number detection, format, # culture) below, once all items are known, because they are evidence for each other. $textOf = { param($x, $what) if (& $isRecord $x) { $t = Get-VellumPdfPropertyValue -InputObject $x -Name 'Text' if ($null -eq $t) { throw "Add-VellumPdfPageList: $what has no Text; give text or a hashtable with a Text key (an empty string draws a marker only)." } $itemFormat = Get-VellumPdfPropertyValue -InputObject $x -Name 'Format' $itemParse = Get-VellumPdfPropertyValue -InputObject $x -Name 'Parse' [pscustomobject]@{ Value = $t; Format = $(if ($null -ne $itemFormat) { [string]$itemFormat } else { $Format }); Parse = ($null -eq $itemParse -or [bool]$itemParse) } } else { [pscustomobject]@{ Value = $x; Format = $Format; Parse = $true } } } $entries = foreach ($spec in $Item) { if ($null -eq $spec) { throw 'Add-VellumPdfPageList: an item is $null; give text or a hashtable with Text (an empty string draws a marker only).' } $text = & $textOf $spec 'an item' $children = @() if (& $isRecord $spec) { $rawChildren = Get-VellumPdfPropertyValue -InputObject $spec -Name 'Children' if ($null -ne $rawChildren) { $children = @($rawChildren) } } $childEntries = @(foreach ($child in $children) { if ($null -eq $child) { throw 'Add-VellumPdfPageList: a child item is $null; give text or a hashtable with Text.' } if ((& $isRecord $child) -and @(Get-VellumPdfPropertyValue -InputObject $child -Name 'Children').Where({ $null -ne $_ }).Count -gt 0) { throw 'Add-VellumPdfPageList: the list has one nesting level; an item of Children cannot have Children of its own.' } [pscustomobject]@{ Source = (& $textOf $child 'a child item'); Text = ''; Children = @(); Lines = $null } }) [pscustomobject]@{ Source = $text; Text = ''; Children = $childEntries; Lines = $null } } $entries = @($entries) $slotList = [System.Collections.Generic.List[object]]::new() $slotOwners = [System.Collections.Generic.List[object]]::new() $itemNumber = 0 foreach ($entry in $entries) { $itemNumber++ $slotList.Add((New-VellumPdfTextSlot -Value $entry.Source.Value -Format $entry.Source.Format -Parse $entry.Source.Parse -Group 0 -What "item $itemNumber")) $slotOwners.Add($entry) $childNumber = 0 foreach ($childEntry in $entry.Children) { $childNumber++ $slotList.Add((New-VellumPdfTextSlot -Value $childEntry.Source.Value -Format $childEntry.Source.Format -Parse $childEntry.Source.Parse -Group 0 -What "child $childNumber of item $itemNumber")) $slotOwners.Add($childEntry) } } Convert-VellumPdfSlotText -Slot $slotList.ToArray() -Culture $cultures.Culture -InputCulture $cultures.InputCulture ` -NoNumberParsing:$NoNumberParsing -CommandName 'Add-VellumPdfPageList' for ($si = 0; $si -lt $slotOwners.Count; $si++) { $slotOwners[$si].Text = $slotList[$si].Text } $resolved = Get-VellumPdfPageFont -Document $Document -FontHandle $FontHandle -Font $Font -CommandName 'Add-VellumPdfPageList' $allText = @($entries | ForEach-Object { $_.Text; $_.Children | ForEach-Object { $_.Text } }) Assert-VellumPdfValidText -Text $allText -CommandName 'Add-VellumPdfPageList' Write-VellumPdfMissingGlyphWarning -Handle $resolved.Handle -Text $allText -CommandName 'Add-VellumPdfPageList' if (-not $resolved.Handle -and $Document.Conformance -eq [VellumPdf.Document.PdfConformance]::None) { Write-VellumPdfEncodingWarning -Text $allText -CommandName 'Add-VellumPdfPageList' } $bullet = $Marker if ($resolved.Handle -and $resolved.Handle.GetGlyphId(([System.Text.Rune]::GetRuneAt($bullet, 0)).Value) -eq 0) { $bullet = '-' } $childBullet = if ($bullet -eq '-') { '-' } else { [string][char]0x2013 } if (-not $resolved.Handle -and $Document.Conformance -eq [VellumPdf.Document.PdfConformance]::None -and -not $bound.ContainsKey('Marker')) { $childBullet = '-' } # a. b. ... z. aa. ab. ... $letters = { param([int]$n) $label = '' do { $label = [string][char](97 + ($n % 26)) + $label; $n = [Math]::Floor($n / 26) - 1 } while ($n -ge 0) $label } $markerFor = { param($index, $level) if ($Numbered) { if ($level -eq 0) { "$($index + 1)." } else { "$(& $letters $index)." } } elseif ($level -eq 0) { $bullet } else { $childBullet } } # The marker column grows with the widest marker (numbers of 100 and above, long letter labels). if (-not $bound.ContainsKey('Indent')) { $widest = 0.0 for ($i = 0; $i -lt $entries.Count; $i++) { $widest = [Math]::Max($widest, (Measure-VellumPdfTextWidth -Resolved $resolved -Text (& $markerFor $i 0) -FontSize $FontSize)) for ($j = 0; $j -lt $entries[$i].Children.Count; $j++) { $widest = [Math]::Max($widest, (Measure-VellumPdfTextWidth -Resolved $resolved -Text (& $markerFor $j 1) -FontSize $FontSize)) } } $markerIndent = [Math]::Max($markerIndent, $widest + 0.5 * $FontSize) } if ($Width -le 2 * $markerIndent + 1) { throw "Add-VellumPdfPageList: -Width $Width is too narrow for a marker column of $([math]::Round($markerIndent, 1)) pt and one nested level." } # --- measure ----------------------------------------------------------------------------------- # Every entry becomes a placed record; children are kept with their parent for drawing. $layout = @{ Cursor = $Y } $place = { param($entry, $index, $level) $left = $X + $level * $markerIndent $bodyWidth = $Width - ($level + 1) * $markerIndent $entry.Lines = Split-VellumPdfTextLine -Text $entry.Text -Width $bodyWidth -Resolved $resolved -FontSize $FontSize $textHeight = $entry.Lines.Count * $Leading $record = [pscustomobject]@{ Entry = $entry; Level = $level; Index = $index; Left = $left; BodyLeft = $left + $markerIndent; BodyWidth = $bodyWidth Top = $layout.Cursor; TextHeight = $textHeight; Children = [System.Collections.Generic.List[object]]::new() } $layout.Cursor -= $textHeight + $ItemSpacing $record } $placed = [System.Collections.Generic.List[object]]::new() $itemCount = 0 for ($i = 0; $i -lt $entries.Count; $i++) { $top = & $place $entries[$i] $i 0 $itemCount++ for ($j = 0; $j -lt $entries[$i].Children.Count; $j++) { $top.Children.Add((& $place $entries[$i].Children[$j] $j 1)); $itemCount++ } $placed.Add($top) } $bottom = $layout.Cursor + $ItemSpacing $height = $Y - $bottom $pdfPage = $Document.Pages[$Page - 1] if ($bottom -lt $pdfPage.MediaBox.LlY) { throw ("Add-VellumPdfPageList: the list is $([math]::Round($height, 1)) pt tall but only " + "$([math]::Round($Y - $pdfPage.MediaBox.LlY, 1)) pt are left below -Y on page $Page.") } # --- draw --------------------------------------------------------------------------------------- $canvas = Get-VellumPdfPageCanvas -Document $Document -Page $Page -CommandName 'Add-VellumPdfPageList' if ($resolved.Handle) { $Document.RegisterEmbeddedFontUsage($pdfPage, $resolved.Handle) } # A colour operator is written only for a colour that was asked for (DeviceRGB would fail PDF/A with a CMYK intent). $rgb = if ($bound.ContainsKey('Color')) { ConvertTo-VellumColor $Color -Document $Document -CommandName 'Add-VellumPdfPageList' } $tagged = $Document.Tagged $oneLine = { param($text) , @([pscustomobject]@{ Words = [string[]]@($text); Last = $true }) } $drawLines = { param($lines, $left, $width, $top) [void]$canvas.SaveState() try { if ($rgb) { [void]$canvas.SetFillColorRgb($rgb[0], $rgb[1], $rgb[2]) } Write-VellumPdfTextLine -Canvas $canvas -Resolved $resolved -FontSize $FontSize -Lines $lines -X $left -Top $top -Width $width -Leading $Leading -Align Left } finally { [void]$canvas.RestoreState() } } # Draws one entry as LI > Lbl + LBody (or only marker and text when the document is untagged). $drawItem = { param($record, $bodyIsGroup) $markerText = & $markerFor $record.Index $record.Level $markerLines = & $oneLine $markerText if (-not $tagged) { & $drawLines $markerLines $record.Left $markerIndent $record.Top & $drawLines $record.Entry.Lines $record.BodyLeft $record.BodyWidth $record.Top return } Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Structure -StructType Lbl -Draw { & $drawLines $markerLines $record.Left $markerIndent $record.Top } if ($bodyIsGroup) { Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Structure -StructType P -Draw { & $drawLines $record.Entry.Lines $record.BodyLeft $record.BodyWidth $record.Top } } else { Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Structure -StructType LBody -Draw { & $drawLines $record.Entry.Lines $record.BodyLeft $record.BodyWidth $record.Top } } } [void]$canvas.SaveState() try { $listGroup = $null if ($tagged) { $listGroup = [VellumPdf.Document.PdfStructElem]::new('L') if ($Language) { $listGroup.Language = $Language } Push-VellumPdfStructParent -Document $Document -Element $listGroup } try { foreach ($record in $placed) { $listItem = $null if ($tagged) { $listItem = [VellumPdf.Document.PdfStructElem]::new('LI') Add-VellumPdfStructElement -Document $Document -Element $listItem Push-VellumPdfStructParent -Document $Document -Element $listItem } try { if ($record.Children.Count -eq 0) { & $drawItem $record $false } else { # With children, LBody is a group: the item's text paragraph and the nested list. $itemBody = $null $nestedList = $null if ($tagged) { $itemBody = [VellumPdf.Document.PdfStructElem]::new('LBody') $nestedList = [VellumPdf.Document.PdfStructElem]::new('L') $markerLines = & $oneLine (& $markerFor $record.Index 0) Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Structure -StructType Lbl -Draw { & $drawLines $markerLines $record.Left $markerIndent $record.Top } Add-VellumPdfStructElement -Document $Document -Element $itemBody Push-VellumPdfStructParent -Document $Document -Element $itemBody } try { if ($tagged) { Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Structure -StructType P -Draw { & $drawLines $record.Entry.Lines $record.BodyLeft $record.BodyWidth $record.Top } [void]$itemBody.AddChild($nestedList) Push-VellumPdfStructParent -Document $Document -Element $nestedList } else { & $drawItem $record $false } try { foreach ($child in $record.Children) { $childItem = $null if ($tagged) { $childItem = [VellumPdf.Document.PdfStructElem]::new('LI') Add-VellumPdfStructElement -Document $Document -Element $childItem Push-VellumPdfStructParent -Document $Document -Element $childItem } try { & $drawItem $child $false } finally { if ($tagged) { Pop-VellumPdfStructParent -Document $Document } } } } finally { if ($tagged) { Pop-VellumPdfStructParent -Document $Document } } } finally { if ($tagged) { Pop-VellumPdfStructParent -Document $Document } } } } finally { if ($tagged) { Pop-VellumPdfStructParent -Document $Document } } } } finally { if ($tagged) { Pop-VellumPdfStructParent -Document $Document; Add-VellumPdfStructElement -Document $Document -Element $listGroup } } } finally { [void]$canvas.RestoreState() } if ($PassThru) { [pscustomobject]@{ Document = $Document; Height = $height; Bottom = $bottom; ItemCount = $itemCount } } else { $Document } } catch { $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfPageList')) } } } |