Private/PresentationMedia.ps1

function Resolve-TerminalImagePath {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.SlideElement]$Element)

    if ($Element.Kind -ne [TerminalSlides.Schema.V1.ElementKind]::Image) { throw 'Only image elements have media paths.' }
    $path = $Element.Payload.Path
    if ([IO.Path]::IsPathRooted($path)) { return [IO.Path]::GetFullPath($path) }
    $origin = Get-TerminalMediaOrigin $Element
    if (-not $origin) { throw "Relative image '$path' has no source origin." }
    return [IO.Path]::GetFullPath((Join-Path $origin $path))
}

function Copy-TerminalElementWithPayload {
    param(
        [Parameter(Mandatory)][TerminalSlides.Schema.V1.SlideElement]$Element,
        [Parameter(Mandatory)][TerminalSlides.Schema.V1.ElementPayload]$Payload
    )

    $copy = [TerminalSlides.Schema.V1.SlideElement]::new($Element.Kind, $Payload)
    foreach ($property in 'Id','Region','X','Y','Width','Height','Alignment','VerticalAlignment','Padding','ForegroundColor','BackgroundColor','Border','BorderStyle','RevealStep','OverflowBehavior') {
        $copy.$property = $Element.$property
    }
    return $copy
}

function Add-TerminalPortableAsset {
    param(
        [Parameter(Mandatory)][string]$SourcePath,
        [Parameter(Mandatory)][string]$StagingDirectory
    )

    $snapshotPath = Join-Path $StagingDirectory ('.asset-' + [guid]::NewGuid().ToString('N') + '.tmp')
    try {
        Copy-Item -LiteralPath $SourcePath -Destination $snapshotPath
        $hash = (Get-FileHash -LiteralPath $snapshotPath -Algorithm SHA256).Hash.ToLowerInvariant()
        $extension = [IO.Path]::GetExtension($SourcePath).ToLowerInvariant()
        $fileName = $hash + $extension
        $destinationPath = Join-Path $StagingDirectory $fileName
        if (-not (Test-Path -LiteralPath $destinationPath -PathType Leaf)) {
            [IO.File]::Move($snapshotPath, $destinationPath)
        }
        return $fileName
    }
    finally {
        if (Test-Path -LiteralPath $snapshotPath) {
            Remove-Item -LiteralPath $snapshotPath -Force -ErrorAction SilentlyContinue
        }
    }
}

function Get-TerminalMissingDirectoryChain {
    param([Parameter(Mandatory)][string]$Path)

    $missing = [Collections.Generic.List[string]]::new()
    $directory = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Path))
    while (-not $directory.Exists) {
        $missing.Add($directory.FullName)
        $directory = $directory.Parent
    }
    return $missing.ToArray()
}

function Remove-TerminalCreatedDirectoriesIfEmpty {
    param([Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Paths)

    foreach ($path in $Paths) {
        if (-not (Test-Path -LiteralPath $path -PathType Container)) { continue }
        try { [IO.Directory]::Delete($path, $false) }
        catch [IO.IOException] { }
        catch [UnauthorizedAccessException] { }
    }
}

function Move-TerminalDirectoryAtomically {
    param(
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$Destination
    )

    [IO.Directory]::Move($Source, $Destination)
}

function Install-TerminalStagedAssets {
    param([Parameter(Mandatory)][object]$Transaction)

    $backup = $null
    if (Test-Path -LiteralPath $Transaction.FinalDirectory) {
        if (-not $Transaction.ReplaceExistingAssets) {
            throw "Asset path '$($Transaction.FinalDirectory)' already exists. Use -Force to overwrite it."
        }
        $backup = $Transaction.FinalDirectory + '.' + [guid]::NewGuid().ToString('N') + '.backup'
        Move-Item -LiteralPath $Transaction.FinalDirectory -Destination $backup
    }
    try {
        Move-TerminalDirectoryAtomically -Source $Transaction.StagingDirectory -Destination $Transaction.FinalDirectory
    }
    catch {
        if ($backup) {
            if (Test-Path -LiteralPath $Transaction.FinalDirectory) {
                Remove-Item -LiteralPath $Transaction.FinalDirectory -Recurse -Force -ErrorAction SilentlyContinue
            }
            if (Test-Path -LiteralPath $backup) {
                Move-Item -LiteralPath $backup -Destination $Transaction.FinalDirectory
            }
        }
        throw
    }
    return $backup
}

function New-TerminalPortableExport {
    param(
        [Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation,
        [Parameter(Mandatory)][string]$TargetPath,
        [Parameter(Mandatory)][bool]$Overwrite
    )

    $targetDirectory = Split-Path -Parent $TargetPath
    $createdTargetDirectories = @(Get-TerminalMissingDirectoryChain -Path $targetDirectory)
    $assetDirectoryName = [IO.Path]::GetFileName($TargetPath) + '.assets'
    $assetDirectory = Join-Path $targetDirectory $assetDirectoryName
    $imageElements = @($Presentation.Slides.Elements | Where-Object Kind -eq Image)
    $hasAssets = $imageElements.Count -gt 0
    $stagingDirectory = if ($hasAssets) {
        Join-Path $targetDirectory ('.' + $assetDirectoryName + '.' + [guid]::NewGuid().ToString('N') + '.tmp')
    }
    else { $null }

    if ($hasAssets -and (Test-Path -LiteralPath $assetDirectory) -and -not $Overwrite) {
        throw "Asset path '$assetDirectory' already exists. Use -Force to overwrite it."
    }

    try {
        [void][IO.Directory]::CreateDirectory($targetDirectory)
        if ($hasAssets) {
            [void][IO.Directory]::CreateDirectory($stagingDirectory)
            foreach ($slide in $Presentation.Slides) {
                for ($index = 0; $index -lt $slide.Elements.Count; $index++) {
                    $element = $slide.Elements[$index]
                    if ($element.Kind -ne [TerminalSlides.Schema.V1.ElementKind]::Image) { continue }

                    $sourcePath = Resolve-TerminalImagePath $element
                    if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
                        throw "Image '$($element.Payload.Path)' was not found at '$sourcePath'."
                    }
                    $fileName = Add-TerminalPortableAsset -SourcePath $sourcePath -StagingDirectory $stagingDirectory

                    $portablePath = ($assetDirectoryName + '/' + $fileName).Replace('\', '/')
                    $portable = Copy-TerminalElementWithPayload -Element $element -Payload (
                        [TerminalSlides.Schema.V1.ImagePayload]::new($portablePath, $element.Payload.AltText)
                    )
                    Set-TerminalMediaOrigin -Element $portable -Directory $targetDirectory
                    $slide.Elements[$index] = $portable
                }
            }
        }
        return [pscustomobject]@{
            Presentation = $Presentation
            Transaction = [pscustomobject]@{
                StagingDirectory = $stagingDirectory
                FinalDirectory = $assetDirectory
                HasAssets = $hasAssets
                ReplaceExistingAssets = $Overwrite
                CreatedTargetDirectories = $createdTargetDirectories
            }
        }
    }
    catch {
        if ($stagingDirectory) {
            Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction SilentlyContinue
        }
        Remove-TerminalCreatedDirectoriesIfEmpty -Paths $createdTargetDirectories
        throw
    }
}

function ConvertTo-TerminalMarkdownImageAlt {
    param([AllowNull()][string]$Text)
    return ([string]$Text).Replace('\', '\\').Replace(']', '\]')
}

function ConvertTo-TerminalMarkdownImageDestination {
    param([Parameter(Mandatory)][string]$Path)

    $normalized = $Path.Replace('\', '/')
    $encoded = ($normalized -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/'
    return '<' + $encoded.Replace('>', '%3E').Replace('<', '%3C') + '>'
}