Private/PresentationWireCodec.ps1
|
$script:TerminalSlidesLegacyWireSchema = 'https://terminalslides.dev/schema/presentation/v1' $script:TerminalSlidesLegacyWireVersion = 1 $script:TerminalSlidesWireSchema = 'https://terminalslides.dev/schema/presentation/v2' $script:TerminalSlidesWireVersion = 2 $script:TerminalSlidesDateTimePrefix = 'terminalslides:datetime:' $script:TerminalSlidesDateTimeOffsetPrefix = 'terminalslides:datetimeoffset:' $script:TerminalSlidesWireDepthPerSemanticLevel = 3 $script:TerminalSlidesWireEnvelopeDepth = 8 $script:TerminalSlidesMaximumWireDepth = $script:TerminalSlidesWireEnvelopeDepth + (($script:TerminalSlidesMaximumValueDepth + 1) * $script:TerminalSlidesWireDepthPerSemanticLevel) $script:TerminalSlidesPersistedArrayElementTypes = @{ 'System.Object'=[object]; 'System.String'=[string]; 'System.Char'=[char]; 'System.Boolean'=[bool]; 'System.Byte'=[byte] 'System.SByte'=[sbyte]; 'System.Int16'=[int16]; 'System.UInt16'=[uint16]; 'System.Int32'=[int] 'System.UInt32'=[uint32]; 'System.Int64'=[int64]; 'System.UInt64'=[uint64]; 'System.Single'=[single] 'System.Double'=[double]; 'System.Decimal'=[decimal]; 'System.DateTime'=[datetime] 'System.DateTimeOffset'=[datetimeoffset]; 'System.TimeSpan'=[timespan]; 'System.Guid'=[guid] 'System.Uri'=[uri]; 'System.Version'=[version]; 'System.Text.RegularExpressions.Regex'=[regex] } $script:TerminalSlidesTaggedValueFields = [Collections.Generic.Dictionary[string,string[]]]::new( [StringComparer]::Ordinal ) foreach ($type in 'String','Char','Boolean','SByte','Byte','Int16','UInt16','Int32','UInt32','Int64', 'UInt64','Single','Double','Decimal','DateTime','DateTimeOffset','TimeSpan','Guid','Uri','Version','Regex') { $script:TerminalSlidesTaggedValueFields.Add($type, [string[]]@('Type','Value')) } $script:TerminalSlidesTaggedValueFields.Add('Null', [string[]]@('Type')) $script:TerminalSlidesTaggedValueFields.Add('Map', [string[]]@('Type','Entries')) $script:TerminalSlidesTaggedValueFields.Add('OrderedMap', [string[]]@('Type','Entries')) $script:TerminalSlidesTaggedValueFields.Add('Object', [string[]]@('Type','Properties')) $script:TerminalSlidesTaggedValueFields.Add('Array', [string[]]@('Type','ElementType','Items')) $script:TerminalSlidesTaggedValueFields.Add('ArrayList', [string[]]@('Type','Items')) $script:TerminalSlidesCanonicalStringValueTypes = [Collections.Generic.HashSet[string]]::new( [string[]]@( 'String','Char','SByte','Byte','Int16','UInt16','Int32','UInt32','Int64','UInt64','Single','Double', 'Decimal','DateTime','DateTimeOffset','TimeSpan','Guid','Uri','Version' ), [StringComparer]::Ordinal ) function ConvertFrom-TerminalUtf8Bytes { param( [Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes, [switch]$RemoveByteOrderMark ) $offset = if ($RemoveByteOrderMark -and $Bytes.Length -ge 3 -and $Bytes[0] -eq 0xef -and $Bytes[1] -eq 0xbb -and $Bytes[2] -eq 0xbf) { 3 } else { 0 } try { return $script:TerminalSlidesStrictUtf8.GetString($Bytes, $offset, $Bytes.Length - $offset) } catch [Text.DecoderFallbackException] { throw 'TerminalSlides structured input requires valid UTF-8 text.' } } function Assert-TerminalCanonicalTaggedScalarValue { param( [Parameter(Mandatory)][string]$Type, [AllowNull()][object]$Value ) $valid = if ($Type -ceq 'Boolean') { $Value -is [bool] } elseif ($script:TerminalSlidesCanonicalStringValueTypes.Contains($Type)) { $Value -is [string] -and ($Type -cne 'Char' -or $Value.Length -eq 1) } else { $true } if (-not $valid) { throw "Persisted tagged type '$Type' requires a canonical Value representation." } } function Assert-TerminalExactObjectFields { param( [Parameter(Mandatory)][System.Collections.IDictionary]$Value, [Parameter(Mandatory)][string[]]$Fields, [Parameter(Mandatory)][string]$Subject ) $expected = [Collections.Generic.HashSet[string]]::new($Fields, [StringComparer]::Ordinal) $present = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($field in $Value.Keys) { if ($field -isnot [string] -or -not $expected.Contains($field)) { throw "$Subject is malformed: field '$field' is not supported." } [void]$present.Add($field) } foreach ($field in $Fields) { if (-not $present.Contains($field)) { throw "$Subject is malformed: field '$field' is required." } } } function Get-TerminalTaggedNodeArray { param([AllowNull()][object]$Value) return ,([object[]]$Value) } function Get-TerminalTaggedMembers { param( [Parameter(Mandatory)][System.Collections.IDictionary]$Node, [Parameter(Mandatory)][string]$Field ) return Get-TerminalTaggedNodeArray -Value $Node[$Field] } function Get-TerminalPersistedArrayElementType { param([Parameter(Mandatory)][string]$TypeName) $elementType = $script:TerminalSlidesPersistedArrayElementTypes[$TypeName] if (-not $elementType) { throw "Array element type '$TypeName' is not supported." } return $elementType } function ConvertFrom-TerminalPersistedScalarText { param( [Parameter(Mandatory)][string]$Kind, [AllowNull()][object]$Value ) if ($null -eq $Value) { return $null } $culture = [Globalization.CultureInfo]::InvariantCulture if ($Kind -eq 'DateTime' -and $Value -is [datetime]) { return ([datetime]$Value).ToString('o', $culture) } if ($Kind -eq 'DateTimeOffset') { if ($Value -is [datetimeoffset]) { return ([datetimeoffset]$Value).ToString('o', $culture) } if ($Value -is [datetime]) { return ([datetimeoffset][datetime]$Value).ToString('o', $culture) } } $text = [string]$Value if ($Kind -eq 'DateTime' -and $text.StartsWith($script:TerminalSlidesDateTimePrefix, [StringComparison]::Ordinal)) { return $text.Substring($script:TerminalSlidesDateTimePrefix.Length) } if ($Kind -eq 'DateTimeOffset' -and $text.StartsWith($script:TerminalSlidesDateTimeOffsetPrefix, [StringComparison]::Ordinal)) { return $text.Substring($script:TerminalSlidesDateTimeOffsetPrefix.Length) } return $text } function Assert-TerminalPersistableRegexOptions { param([Parameter(Mandatory)][Text.RegularExpressions.RegexOptions]$Options) $cultureInvariant = [int]([Text.RegularExpressions.RegexOptions]::CultureInvariant) if (([int]$Options -band $cultureInvariant) -eq 0) { throw 'Regular-expression persistence requires RegexOptions.CultureInvariant.' } } function ConvertFrom-TerminalPersistedRegex { param( [Parameter(Mandatory)][AllowNull()][object]$Value, [switch]$RequireCanonical ) $hasMatchTimeout = $false foreach ($field in $Value.Keys) { if ($field -is [string] -and $field -ceq 'MatchTimeoutTicks') { $hasMatchTimeout = $true } } $pattern = [string]$Value['Pattern'] try { $optionValue = [int]::Parse([string]$Value['Options'], [Globalization.CultureInfo]::InvariantCulture) $options = [Text.RegularExpressions.RegexOptions]$optionValue $validated = [regex]::new($pattern, $options) } catch { throw "Persisted regular-expression value is malformed: $($_.Exception.Message)" } if (-not $hasMatchTimeout) { return $validated } Assert-TerminalPersistableRegexOptions -Options $options try { $ticks = [int64]::Parse([string]$Value['MatchTimeoutTicks'], [Globalization.CultureInfo]::InvariantCulture) if ($RequireCanonical -and $ticks.ToString([Globalization.CultureInfo]::InvariantCulture) -cne $Value['MatchTimeoutTicks']) { throw 'MatchTimeoutTicks does not use its canonical text representation.' } return [regex]::new($pattern, $options, [timespan]::FromTicks($ticks)) } catch { throw "Persisted regular-expression match timeout is invalid: $($_.Exception.Message)" } } function ConvertTo-TerminalTaggedValue { param( [AllowNull()][object]$Value, [int]$Depth = 0, [AllowNull()][psobject]$TraversalContext ) if ($null -eq $TraversalContext) { $TraversalContext = New-TerminalSemanticTraversalContext } Assert-TerminalSemanticTraversalDepth -Depth $Depth -MaximumDepth $TraversalContext.MaximumDepth if ($null -eq $Value) { return [ordered]@{ Type = 'Null' } } $typeName = switch ($Value.GetType().FullName) { 'System.String' { 'String' } 'System.Char' { 'Char' } 'System.Boolean' { 'Boolean' } 'System.SByte' { 'SByte' } 'System.Byte' { 'Byte' } 'System.Int16' { 'Int16' } 'System.UInt16' { 'UInt16' } 'System.Int32' { 'Int32' } 'System.UInt32' { 'UInt32' } 'System.Int64' { 'Int64' } 'System.UInt64' { 'UInt64' } 'System.Single' { 'Single' } 'System.Double' { 'Double' } 'System.Decimal' { 'Decimal' } 'System.DateTime' { 'DateTime' } 'System.DateTimeOffset' { 'DateTimeOffset' } 'System.TimeSpan' { 'TimeSpan' } 'System.Guid' { 'Guid' } 'System.Uri' { 'Uri' } 'System.Version' { 'Version' } 'System.Text.RegularExpressions.Regex' { 'Regex' } default { $null } } if ($typeName) { $serialized = switch ($typeName) { 'Boolean' { [bool]$Value } { $_ -in 'SByte','Byte','Int16','UInt16','Int32','UInt32','Int64','UInt64' } { $Value.ToString([Globalization.CultureInfo]::InvariantCulture) } 'Single' { ([single]$Value).ToString('R', [Globalization.CultureInfo]::InvariantCulture) } 'Double' { ([double]$Value).ToString('R', [Globalization.CultureInfo]::InvariantCulture) } 'Decimal' { ([decimal]$Value).ToString([Globalization.CultureInfo]::InvariantCulture) } 'DateTime' { ([datetime]$Value).ToString('o', [Globalization.CultureInfo]::InvariantCulture) } 'DateTimeOffset' { ([datetimeoffset]$Value).ToString('o', [Globalization.CultureInfo]::InvariantCulture) } 'TimeSpan' { ([timespan]$Value).ToString('c', [Globalization.CultureInfo]::InvariantCulture) } 'Guid' { ([guid]$Value).ToString('D') } 'Uri' { ([uri]$Value).OriginalString } 'Regex' { Assert-TerminalPersistableRegexOptions -Options $Value.Options [ordered]@{ Pattern = $Value.ToString() Options = [int]$Value.Options MatchTimeoutTicks = $Value.MatchTimeout.Ticks.ToString([Globalization.CultureInfo]::InvariantCulture) } } default { [string]$Value } } return [ordered]@{ Type = $typeName; Value = $serialized } } Enter-TerminalSemanticReference -Value $Value -TraversalContext $TraversalContext try { if ($Value -is [System.Collections.IDictionary]) { Assert-TerminalUnambiguousDictionaryKeys -Dictionary $Value $entries = foreach ($key in (Get-TerminalCanonicalDictionaryKeys -Dictionary $Value)) { $item = ConvertTo-TerminalTaggedValue -Value $Value[$key] -Depth ($Depth + 1) -TraversalContext $TraversalContext [ordered]@{ Name = $key; Value = $item } } $kind = if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { 'OrderedMap' } else { 'Map' } return [ordered]@{ Type = $kind; Entries = @($entries) } } if ($Value.GetType().IsArray) { Assert-TerminalSupportedArrayShape -ArrayType $Value.GetType() $elementTypeName = $Value.GetType().GetElementType().FullName $null = Get-TerminalPersistedArrayElementType -TypeName $elementTypeName $items = [Collections.Generic.List[object]]::new() foreach ($item in $Value) { $items.Add((ConvertTo-TerminalTaggedValue -Value $item -Depth ($Depth + 1) -TraversalContext $TraversalContext)) } return [ordered]@{ Type = 'Array'; ElementType = $elementTypeName; Items = $items.ToArray() } } if ($Value -is [System.Collections.ArrayList]) { $items = foreach ($item in $Value) { ConvertTo-TerminalTaggedValue -Value $item -Depth ($Depth + 1) -TraversalContext $TraversalContext } return [ordered]@{ Type = 'ArrayList'; Items = @($items) } } if ($Value -is [System.Management.Automation.PSCustomObject]) { $properties = foreach ($property in $Value.PSObject.Properties) { $item = ConvertTo-TerminalTaggedValue -Value $property.Value -Depth ($Depth + 1) -TraversalContext $TraversalContext [ordered]@{ Name = $property.Name; Value = $item } } return [ordered]@{ Type = 'Object'; Properties = @($properties) } } throw "Value type '$($Value.GetType().FullName)' cannot be persisted safely." } finally { Exit-TerminalSemanticReference -Value $Value -TraversalContext $TraversalContext } } function Assert-TerminalCanonicalTaggedScalarText { param( [Parameter(Mandatory)][string]$Type, [AllowNull()][object]$Value ) if ($Type -in 'Null','String','Char','Boolean','Map','OrderedMap','Object','Array','ArrayList','Regex') { return } $culture = [Globalization.CultureInfo]::InvariantCulture try { $canonical = switch ($Type) { 'SByte' { [sbyte]::Parse($Value, $culture).ToString($culture) } 'Byte' { [byte]::Parse($Value, $culture).ToString($culture) } 'Int16' { [int16]::Parse($Value, $culture).ToString($culture) } 'UInt16' { [uint16]::Parse($Value, $culture).ToString($culture) } 'Int32' { [int32]::Parse($Value, $culture).ToString($culture) } 'UInt32' { [uint32]::Parse($Value, $culture).ToString($culture) } 'Int64' { [int64]::Parse($Value, $culture).ToString($culture) } 'UInt64' { [uint64]::Parse($Value, $culture).ToString($culture) } 'Single' { [single]::Parse($Value, $culture).ToString('R', $culture) } 'Double' { [double]::Parse($Value, $culture).ToString('R', $culture) } 'Decimal' { [decimal]::Parse($Value, $culture).ToString($culture) } 'DateTime' { [void](ConvertFrom-TerminalDateTimeText -Text $Value) if ($Value -cnotmatch '\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}(?:Z|[+-]\d{2}:\d{2})?\z') { throw 'DateTime text must use the round-trip format.' } if ($Value -cmatch '-00:00\z') { throw 'DateTime text must not use a negative-zero offset.' } [string]$Value } 'DateTimeOffset' { [datetimeoffset]::ParseExact($Value, 'o', $culture, [Globalization.DateTimeStyles]::RoundtripKind).ToString('o', $culture) } 'TimeSpan' { [timespan]::ParseExact($Value, 'c', $culture).ToString('c', $culture) } 'Guid' { [guid]::ParseExact($Value, 'D').ToString('D') } 'Uri' { [uri]::new($Value, [UriKind]::RelativeOrAbsolute).OriginalString } 'Version' { [version]::new($Value).ToString() } } } catch { throw "Persisted tagged type '$Type' has invalid canonical text: $($_.Exception.Message)" } if ([string]$Value -cne $canonical) { throw "Persisted tagged type '$Type' does not use its canonical text representation." } } function ConvertFrom-TerminalTaggedValue { param( [Parameter(Mandatory)][System.Collections.IDictionary]$Node, [int]$Depth = 0, [switch]$RequireCanonical ) Assert-TerminalCurrentTaggedNode -Node $Node -Depth $Depth -Recurse $false -RequireCanonical:$RequireCanonical $culture = [Globalization.CultureInfo]::InvariantCulture $type = [string]$Node['Type'] if ($RequireCanonical) { Assert-TerminalCanonicalTaggedScalarText -Type $type -Value $Node['Value'] } switch ($type) { 'Null' { return $null } 'String' { return [string]$Node.Value } 'Char' { return [char][string]$Node.Value } 'Boolean' { return [bool]::Parse([string]$Node.Value) } 'SByte' { return [sbyte]::Parse([string]$Node.Value, $culture) } 'Byte' { return [byte]::Parse([string]$Node.Value, $culture) } 'Int16' { return [int16]::Parse([string]$Node.Value, $culture) } 'UInt16' { return [uint16]::Parse([string]$Node.Value, $culture) } 'Int32' { return [int32]::Parse([string]$Node.Value, $culture) } 'UInt32' { return [uint32]::Parse([string]$Node.Value, $culture) } 'Int64' { return [int64]::Parse([string]$Node.Value, $culture) } 'UInt64' { return [uint64]::Parse([string]$Node.Value, $culture) } 'Single' { return [single]::Parse([string]$Node.Value, $culture) } 'Double' { return [double]::Parse([string]$Node.Value, $culture) } 'Decimal' { return [decimal]::Parse([string]$Node.Value, $culture) } 'DateTime' { $text = ConvertFrom-TerminalPersistedScalarText -Kind DateTime -Value $Node.Value return ConvertFrom-TerminalDateTimeText -Text $text } 'DateTimeOffset' { $text = ConvertFrom-TerminalPersistedScalarText -Kind DateTimeOffset -Value $Node.Value return [datetimeoffset]::ParseExact($text, 'o', $culture, [Globalization.DateTimeStyles]::RoundtripKind) } 'TimeSpan' { return [timespan]::ParseExact([string]$Node.Value, 'c', $culture) } 'Guid' { return [guid]::ParseExact([string]$Node.Value, 'D') } 'Uri' { return [uri]::new([string]$Node.Value, [UriKind]::RelativeOrAbsolute) } 'Version' { return [version]::new([string]$Node.Value) } 'Regex' { return ConvertFrom-TerminalPersistedRegex -Value $Node.Value -RequireCanonical:$RequireCanonical } { $_ -in 'Map','OrderedMap' } { $map = if ($Node.Type -eq 'OrderedMap') { [ordered]@{} } else { @{} } $entries = Get-TerminalTaggedMembers -Node $Node -Field Entries Assert-TerminalUnambiguousNames -Names @($entries | ForEach-Object { $_['Name'] }) -Subject 'Metadata dictionary keys' -Item 'metadata key' foreach ($entry in $entries) { $map[[string]$entry['Name']] = ConvertFrom-TerminalTaggedValue $entry['Value'] ($Depth + 1) -RequireCanonical:$RequireCanonical } return $map } 'Object' { $properties = [ordered]@{} $members = Get-TerminalTaggedMembers -Node $Node -Field Properties Assert-TerminalUnambiguousNames -Names @($members | ForEach-Object { $_['Name'] }) -Subject 'Metadata object properties' -Item 'metadata property' foreach ($property in $members) { $properties[[string]$property['Name']] = ConvertFrom-TerminalTaggedValue $property['Value'] ($Depth + 1) -RequireCanonical:$RequireCanonical } return [pscustomobject]$properties } { $_ -in 'Array','ArrayList' } { $elementType = if ($Node.Type -eq 'Array') { Get-TerminalPersistedArrayElementType -TypeName ([string]$Node.ElementType) } else { $null } $itemNodes = Get-TerminalTaggedNodeArray -Value $Node['Items'] $items = @($itemNodes | ForEach-Object { ConvertFrom-TerminalTaggedValue $_ ($Depth + 1) -RequireCanonical:$RequireCanonical }) if ($Node.Type -eq 'ArrayList') { return ,([Collections.ArrayList]::new($items)) } $array = [Array]::CreateInstance($elementType, $items.Count) for ($index = 0; $index -lt $items.Count; $index++) { $item = $items[$index] if ($null -eq $item) { if ($elementType.IsValueType) { throw "Persisted array element type '$($elementType.FullName)' does not allow null items." } $array.SetValue($null, $index) continue } if (-not $elementType.IsInstanceOfType($item)) { throw "Persisted array item type '$($item.GetType().FullName)' is not assignable to '$($elementType.FullName)'." } $array.SetValue($item, $index) } return ,$array } } } function ConvertTo-TerminalPayloadData { param([Parameter(Mandatory)][TerminalSlides.Schema.V1.ElementPayload]$Payload) switch ($Payload.GetType().Name) { 'TextPayload' { return [ordered]@{ Text = $Payload.Text } } 'CodePayload' { return [ordered]@{ Code = $Payload.Code; Language = $Payload.Language } } 'ImagePayload' { return [ordered]@{ Path = $Payload.Path; AltText = $Payload.AltText } } 'QuotePayload' { return [ordered]@{ Text = $Payload.Text; Attribution = $Payload.Attribution } } 'ChartPayload' { return [ordered]@{ ChartKind = $Payload.ChartKind.ToString(); Title = $Payload.Title Points = @($Payload.Points | ForEach-Object { [ordered]@{ Label = $_.Label; Value = $_.Value.ToString([Globalization.CultureInfo]::InvariantCulture) } }) } } 'DiagramPayload' { return [ordered]@{ Nodes = @($Payload.Nodes | ForEach-Object { [ordered]@{ Id = $_.Id; Label = $_.Label } }) Edges = @($Payload.Edges | ForEach-Object { [ordered]@{ From = $_.From; To = $_.To; Label = $_.Label } }) } } 'TablePayload' { return [ordered]@{ Rows = @($Payload.Rows | ForEach-Object { [ordered]@{ Cells = @($_.Cells | ForEach-Object { [ordered]@{ Name = $_.Name Kind = $_.Value.Kind.ToString() Value = $_.Value.Value } }) } }) } } } } function ConvertTo-TerminalThemeData { param([Parameter(Mandatory)][TerminalSlides.Schema.V1.ThemeDefinition]$Theme) return [ordered]@{ Name = $Theme.Name Background = $Theme.Background Foreground = $Theme.Foreground Primary = $Theme.Primary Accent = $Theme.Accent Muted = $Theme.Muted Heading = $Theme.Heading Border = $Theme.Border CodeTheme = $Theme.CodeTheme CodeBackground = $Theme.CodeBackground CodeForeground = $Theme.CodeForeground BulletSymbol = $Theme.BulletSymbol BoxDrawingStyle = $Theme.BoxDrawingStyle HeadingStyle = $Theme.HeadingStyle ChartPalette = @($Theme.ChartPalette) ErrorColor = $Theme.ErrorColor WarningColor = $Theme.WarningColor SuccessColor = $Theme.SuccessColor Metadata = ConvertTo-TerminalTaggedValue $Theme.Metadata } } function ConvertTo-TerminalHashtableRoot { param( [AllowNull()][object]$Value, [Parameter(Mandatory)][string]$Subject ) if ($Value -isnot [System.Collections.IDictionary]) { throw "$Subject must decode to a map." } $result = @{} foreach ($key in $Value.Keys) { $result[[string]$key] = $Value[$key] } return $result } function ConvertTo-PresentationData { param([Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation) Assert-TerminalWireReadyPresentationModel -Presentation $Presentation $theme = Resolve-TerminalPresentationTheme -Presentation $Presentation $slides = foreach ($slide in $Presentation.Slides) { $maximumRevealStep = Get-TerminalSlideMaximumRevealStep -Slide $slide [ordered]@{ Id=$slide.Id; Index=$slide.Index; Title=$slide.Title; Layout=$slide.Layout; Notes=$slide.Notes Background=$slide.Background; Transition=$slide.Transition; Hidden=$slide.Hidden; MaxRevealStep=$maximumRevealStep Metadata=[ordered]@{ Author=$slide.Metadata.Author; Custom=(ConvertTo-TerminalTaggedValue $slide.Metadata.Custom) } Elements=@($slide.Elements | ForEach-Object { [ordered]@{ Id=$_.Id; Kind=$_.Kind.ToString(); Payload=(ConvertTo-TerminalPayloadData $_.Payload); Region=$_.Region X=$_.X; Y=$_.Y; Width=$_.Width; Height=$_.Height; Alignment=$_.Alignment VerticalAlignment=$_.VerticalAlignment; Padding=$_.Padding; ForegroundColor=$_.ForegroundColor BackgroundColor=$_.BackgroundColor; Border=$_.Border; BorderStyle=$_.BorderStyle RevealStep=$_.RevealStep; OverflowBehavior=$_.OverflowBehavior } }) } } return [ordered]@{ '$schema'=$script:TerminalSlidesWireSchema SchemaVersion=$script:TerminalSlidesWireVersion Presentation=[ordered]@{ Title=$Presentation.Title; Subtitle=$Presentation.Subtitle; Author=$Presentation.Author Description=$Presentation.Description; Theme=$theme.Name; ThemeDefinition=(ConvertTo-TerminalThemeData $theme) Width=$Presentation.Width; Height=$Presentation.Height DefaultTransition=$Presentation.DefaultTransition; DefaultLayout=$Presentation.DefaultLayout CreatedDate=$Presentation.CreatedDate.ToString('o'); ModifiedDate=$Presentation.ModifiedDate.ToString('o') Metadata=[ordered]@{ Title=$Presentation.Metadata.Title; Subtitle=$Presentation.Metadata.Subtitle; Author=$Presentation.Metadata.Author Description=$Presentation.Metadata.Description; Version=$Presentation.Metadata.Version Custom=(ConvertTo-TerminalTaggedValue $Presentation.Metadata.Custom) } Configuration=(ConvertTo-TerminalTaggedValue $Presentation.Configuration) Slides=@($slides) } } } function ConvertFrom-TerminalLegacyData { param([Parameter(Mandatory)][System.Collections.IDictionary]$Data) $presentation = [TerminalSlides.Schema.V1.TerminalPresentation]::new() foreach ($property in 'Title','Subtitle','Author','Description','Theme','Width','Height','DefaultTransition','DefaultLayout','Configuration') { if ($Data.Contains($property)) { $presentation.$property = Copy-TerminalSemanticValue $Data[$property] } } if (($presentation.Width -gt 0 -and $presentation.Width -lt 20) -or ($presentation.Height -gt 0 -and $presentation.Height -lt 10)) { throw 'Presentation dimensions must be automatic (0) or at least 20x10.' } if ($Data.CreatedDate) { $presentation.CreatedDate = ConvertFrom-TerminalDateTimeText -Text ([string]$Data.CreatedDate) } $modified = if ($Data.ModifiedDate) { ConvertFrom-TerminalDateTimeText -Text ([string]$Data.ModifiedDate) } else { $presentation.ModifiedDate } if ($Data.Metadata) { foreach ($property in 'Title','Subtitle','Author','Description','Version') { if ($Data.Metadata.Contains($property)) { $presentation.Metadata.$property = $Data.Metadata[$property] } } if ($Data.Metadata.Contains('Custom')) { $presentation.Metadata.Custom = Copy-TerminalSemanticValue $Data.Metadata.Custom } } foreach ($slideData in @($Data.Slides ?? @())) { $slide = [TerminalSlides.Schema.V1.Slide]::new() foreach ($property in 'Id','Index','Title','Layout','Notes','Background','Transition','Hidden') { if ($slideData.Contains($property)) { $slide.$property = $slideData[$property] } } if ($slideData.Metadata) { $slide.Metadata.Author = $slideData.Metadata.Author if ($slideData.Metadata.Contains('Custom')) { $slide.Metadata.Custom = Copy-TerminalSemanticValue $slideData.Metadata.Custom } } foreach ($legacy in @($slideData.Elements ?? @())) { if (-not $legacy.Type) { throw 'Legacy slide elements require a Type.' } $kind = [TerminalSlides.Schema.V1.ElementKind]([string]$legacy.Type) $payload = switch ($kind.ToString()) { { $_ -in 'Title','Subtitle','Text','Bullet','Box' } { [TerminalSlides.Schema.V1.TextPayload]::new([string]$legacy.Content) } 'Code' { $code = (Get-TerminalSemanticProperty $legacy.Content Code) ?? [string]$legacy.Content $language = (Get-TerminalSemanticProperty $legacy.Content Language) ?? (Get-TerminalSemanticProperty $legacy.Properties Language) ?? 'text' [TerminalSlides.Schema.V1.CodePayload]::new([string]$code, [string]$language) } 'Table' { [TerminalSlides.Schema.V1.TablePayload]::new((ConvertTo-TerminalDataRows $legacy.Content)) } 'Chart' { $points = @($legacy.Content | ForEach-Object { [TerminalSlides.Schema.V1.ChartPoint]::new([string](Get-TerminalSemanticProperty $_ Label), [decimal](Get-TerminalSemanticProperty $_ Value)) }) $chartKind = (Get-TerminalSemanticProperty $legacy.Properties ChartType) ?? 'HorizontalBar' [TerminalSlides.Schema.V1.ChartPayload]::new([TerminalSlides.Schema.V1.ChartPoint[]]$points, [TerminalSlides.Schema.V1.ChartKind]([string]$chartKind), [string](Get-TerminalSemanticProperty $legacy.Properties Title)) } 'Diagram' { $nodes = @((Get-TerminalSemanticProperty $legacy.Content Nodes) | ForEach-Object { [TerminalSlides.Schema.V1.DiagramNode]::new([string](Get-TerminalSemanticProperty $_ Id), [string](Get-TerminalSemanticProperty $_ Label)) }) $edges = @((Get-TerminalSemanticProperty $legacy.Content Edges) | ForEach-Object { [TerminalSlides.Schema.V1.DiagramEdge]::new([string](Get-TerminalSemanticProperty $_ From), [string](Get-TerminalSemanticProperty $_ To), [string](Get-TerminalSemanticProperty $_ Label)) }) [TerminalSlides.Schema.V1.DiagramPayload]::new([TerminalSlides.Schema.V1.DiagramNode[]]$nodes, [TerminalSlides.Schema.V1.DiagramEdge[]]$edges) } 'Image' { [TerminalSlides.Schema.V1.ImagePayload]::new([string](Get-TerminalSemanticProperty $legacy.Content Path), [string](Get-TerminalSemanticProperty $legacy.Content AltText)) } 'Quote' { [TerminalSlides.Schema.V1.QuotePayload]::new([string](Get-TerminalSemanticProperty $legacy.Content Text), [string](Get-TerminalSemanticProperty $legacy.Content Attribution)) } } $element = [TerminalSlides.Schema.V1.SlideElement]::new($kind, $payload) foreach ($property in 'Id','Region','X','Y','Width','Height','Alignment','VerticalAlignment','Padding','ForegroundColor','BackgroundColor','Border','BorderStyle','RevealStep','OverflowBehavior') { if ($legacy.Contains($property)) { $element.$property = $legacy[$property] } } $slide.Elements.Add($element) } $slide.MaxRevealStep = Get-TerminalSlideMaximumRevealStep -Slide $slide $presentation.Slides.Add($slide) } Update-SlideIndices $presentation $presentation.ModifiedDate = $modified $presentation.EmbeddedTheme = Copy-TerminalThemeDefinition (Resolve-TerminalPresentationTheme $presentation) Assert-TerminalWireReadyPresentationModel -Presentation $presentation return $presentation } function New-PresentationFromData { param([Parameter(Mandatory)][System.Collections.IDictionary]$Data) if ($Data.Contains('SchemaVersion') -or $Data.Contains('$schema')) { return ConvertFrom-TerminalCurrentData $Data } return ConvertFrom-TerminalLegacyData $Data } function ConvertTo-TerminalWireJson { param([Parameter(Mandatory)][System.Collections.IDictionary]$Data) $stream = [IO.MemoryStream]::new() $options = [System.Text.Json.JsonWriterOptions]::new() $options.Indented = $false $options.SkipValidation = $false $options.MaxDepth = $script:TerminalSlidesMaximumWireDepth $writer = [System.Text.Json.Utf8JsonWriter]::new($stream, $options) try { Write-TerminalJsonValue -Writer $writer -Value $Data $writer.Flush() $json = $script:TerminalSlidesStrictUtf8.GetString($stream.ToArray()) [void]$script:TerminalSlidesStrictUtf8.GetByteCount($json) return $json } finally { $writer.Dispose() $stream.Dispose() } } function Write-TerminalJsonValue { param( [Parameter(Mandatory)][System.Text.Json.Utf8JsonWriter]$Writer, [AllowNull()][object]$Value ) if ($null -eq $Value) { $Writer.WriteNullValue() return } if ($Value -is [System.Collections.IDictionary]) { foreach ($key in $Value.Keys) { if ($key -isnot [string]) { throw 'TerminalSlides JSON object keys must be strings.' } } $Writer.WriteStartObject() foreach ($key in (Get-TerminalCanonicalDictionaryKeys -Dictionary $Value)) { Assert-TerminalValidUtf16 -Value $key $Writer.WritePropertyName($key) Write-TerminalJsonValue -Writer $Writer -Value $Value[$key] } $Writer.WriteEndObject() return } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { $Writer.WriteStartArray() foreach ($item in $Value) { Write-TerminalJsonValue -Writer $Writer -Value $item } $Writer.WriteEndArray() return } switch ($Value.GetType().FullName) { 'System.String' { Assert-TerminalValidUtf16 -Value $Value $Writer.WriteStringValue([string]$Value) } 'System.Boolean' { $Writer.WriteBooleanValue([bool]$Value) } 'System.SByte' { $Writer.WriteNumberValue([int]$Value) } 'System.Byte' { $Writer.WriteNumberValue([int]$Value) } 'System.Int16' { $Writer.WriteNumberValue([int]$Value) } 'System.UInt16' { $Writer.WriteNumberValue([int]$Value) } 'System.Int32' { $Writer.WriteNumberValue([int]$Value) } 'System.UInt32' { $Writer.WriteNumberValue([uint32]$Value) } 'System.Int64' { $Writer.WriteNumberValue([int64]$Value) } 'System.UInt64' { $Writer.WriteNumberValue([uint64]$Value) } 'System.Single' { $Writer.WriteNumberValue([single]$Value) } 'System.Double' { $Writer.WriteNumberValue([double]$Value) } 'System.Decimal' { $Writer.WriteNumberValue([decimal]$Value) } default { throw "TerminalSlides JSON cannot encode value type '$($Value.GetType().FullName)'." } } } function ConvertFrom-TerminalJsonElement { param([Parameter(Mandatory)][System.Text.Json.JsonElement]$Element) switch ($Element.ValueKind) { 'Object' { $result = [Collections.Specialized.OrderedDictionary]::new([StringComparer]::Ordinal) foreach ($property in $Element.EnumerateObject()) { if ($result.Contains($property.Name)) { throw "JSON contains duplicate property '$($property.Name)'." } $value = ConvertFrom-TerminalJsonElement -Element $property.Value $result.Add($property.Name, $value) } return $result } 'Array' { $items = [Collections.Generic.List[object]]::new() foreach ($item in $Element.EnumerateArray()) { $items.Add((ConvertFrom-TerminalJsonElement -Element $item)) } return ,([object[]]$items.ToArray()) } 'String' { return $Element.GetString() } 'Number' { [int64]$signed = 0 if ($Element.TryGetInt64([ref]$signed)) { return $signed } [uint64]$unsigned = 0 if ($Element.TryGetUInt64([ref]$unsigned)) { return $unsigned } $raw = $Element.GetRawText() if ($raw -notmatch '[.eE]') { return [Numerics.BigInteger]::Parse($raw, [Globalization.CultureInfo]::InvariantCulture) } return $Element.GetDouble() } 'True' { return $true } 'False' { return $false } 'Null' { return $null } default { throw "Unsupported JSON token '$($Element.ValueKind)'." } } } function ConvertFrom-TerminalJsonValue { param([Parameter(Mandatory)][AllowEmptyString()][string]$Json) $options = [System.Text.Json.JsonDocumentOptions]::new() $options.AllowTrailingCommas = $false $options.CommentHandling = [System.Text.Json.JsonCommentHandling]::Disallow $options.MaxDepth = $script:TerminalSlidesMaximumWireDepth $document = [System.Text.Json.JsonDocument]::Parse($Json, $options) try { return ,(ConvertFrom-TerminalJsonElement -Element $document.RootElement) } finally { $document.Dispose() } } function ConvertFrom-TerminalWireJson { param([Parameter(Mandatory)][AllowEmptyString()][string]$Json) $data = ConvertFrom-TerminalJsonValue -Json $Json if ($data -isnot [System.Collections.IDictionary]) { throw 'The TerminalSlides JSON root must be an object.' } return $data } function ConvertTo-TerminalDataMarker { param([Parameter(Mandatory)][System.Collections.IDictionary]$Data) return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((ConvertTo-TerminalWireJson $Data))) } function ConvertFrom-TerminalDataMarker { param([Parameter(Mandatory)][string]$Marker) try { $json = ConvertFrom-TerminalUtf8Bytes -Bytes ([Convert]::FromBase64String($Marker)) return ConvertFrom-TerminalWireJson -Json $json } catch { throw "The TerminalSlides data marker is invalid: $($_.Exception.Message)" } } |