PSMemeGenerator.psm1

#Region './Private/Invoke-MemeImageModification.ps1' -1

function Invoke-MemeImageModification {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ImagePath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputPath,

        [Parameter()]
        [string]$TopText = '',

        [Parameter()]
        [string]$BottomText = ''
    )

    begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand)"
        if (-not $IsWindows) {
            $message = 'PSMemeGenerator requires Windows with PowerShell 7.4+ Core edition. ' +
                       "Current OS: $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription). " +
                       'This is due to System.Drawing dependencies which are only available on Windows.'
            throw $message
        }
        if ($PSVersionTable.PSVersion -lt [version]'7.4') {
            $message = 'PSMemeGenerator requires PowerShell 7.4 or later. ' +
                       "Current version: $($PSVersionTable.PSVersion). " +
                       'Please upgrade PowerShell.'
            throw $message
        }
    }

    process {
        try {
            Write-Verbose "Modifying image $ImagePath and saving to $OutputPath"
            # Load as Bitmap (not Image) so GetPixel is available for content-bounds detection
            $bitmap = New-Object System.Drawing.Bitmap($ImagePath)
            Write-Verbose "Raw bitmap dimensions after load: Width=$($bitmap.Width) Height=$($bitmap.Height)"
            Write-Verbose "EXIF property IDs present: $($bitmap.PropertyIdList -join ', ')"

            # Normalize EXIF orientation so bitmap.Width/Height match the displayed dimensions.
            # Viewers (Windows Photo Viewer, browsers) auto-apply EXIF rotation, but
            # System.Drawing reports raw stored dimensions. Without this correction,
            # centering math uses the wrong axis for portrait images.
            $exifOrientationId = 274
            if ($bitmap.PropertyIdList -contains $exifOrientationId) {
                $orientationValue = $bitmap.GetPropertyItem($exifOrientationId).Value[0]
                Write-Verbose "EXIF orientation tag (274) found, value: $orientationValue"
                $rotateFlipType = switch ($orientationValue) {
                    2 { [System.Drawing.RotateFlipType]::RotateNoneFlipX }
                    3 { [System.Drawing.RotateFlipType]::Rotate180FlipNone }
                    4 { [System.Drawing.RotateFlipType]::Rotate180FlipX }
                    5 { [System.Drawing.RotateFlipType]::Rotate90FlipX }
                    6 { [System.Drawing.RotateFlipType]::Rotate90FlipNone }
                    7 { [System.Drawing.RotateFlipType]::Rotate270FlipX }
                    8 { [System.Drawing.RotateFlipType]::Rotate270FlipNone }
                    default { $null }
                }
                if ($null -ne $rotateFlipType) {
                    $bitmap.RotateFlip($rotateFlipType)
                    $bitmap.RemovePropertyItem($exifOrientationId)
                    Write-Verbose "Applied EXIF orientation correction (tag value: $orientationValue)"
                    Write-Verbose "Bitmap dimensions after EXIF correction: Width=$($bitmap.Width) Height=$($bitmap.Height)"
                } else {
                    Write-Verbose "EXIF orientation value $orientationValue requires no rotation"
                }
            } else {
                Write-Verbose 'No EXIF orientation tag found, using raw dimensions as-is'
            }

            # Detect content width: scan columns from the right inward to find where non-white
            # pixels begin. This handles templates (e.g. Drake) that have a baked-in white panel
            # on the right half — white text on white background would be invisible without this.
            $whiteThreshold = 245
            $sampleStep = [Math]::Max(1, [int]($bitmap.Height / 20))
            $contentWidth = $bitmap.Width
            for ($col = $bitmap.Width - 1; $col -ge [int]($bitmap.Width * 0.4); $col--) {
                $isBlank = $true
                for ($row = 0; $row -lt $bitmap.Height; $row += $sampleStep) {
                    $px = $bitmap.GetPixel($col, $row)
                    if ($px.R -lt $whiteThreshold -or $px.G -lt $whiteThreshold -or $px.B -lt $whiteThreshold) {
                        $isBlank = $false
                        break
                    }
                }
                if (-not $isBlank) {
                    $contentWidth = $col + 1
                    break
                }
            }
            Write-Verbose "Content width detection: storedWidth=$($bitmap.Width)px contentWidth=$($contentWidth)px"

            $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
            $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
            $graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias

            Write-Verbose "Bitmap DPI: HorzRes=$($bitmap.HorizontalResolution) VertRes=$($bitmap.VerticalResolution)"
            Write-Verbose "Graphics DPI: DpiX=$($graphics.DpiX) DpiY=$($graphics.DpiY)"
            Write-Verbose "PixelFormat: $($bitmap.PixelFormat)"

            # Check if Impact font is available; warn if falling back to a system default
            $impactCheck = New-Object System.Drawing.Font('Impact', 12, [System.Drawing.FontStyle]::Bold)
            Write-Verbose "Font resolved: '$($impactCheck.Name)' (requested 'Impact') - $(if ($impactCheck.Name -ne 'Impact') { 'WARNING: Impact not installed, using fallback' } else { 'OK' })"
            $impactCheck.Dispose()

            $whiteBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::White)
            $blackBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::Black)
            $font = $null
            $padding = 10
            $maxFontSize = [float][Math]::Min(40, $contentWidth / 5)
            $availableWidth = $contentWidth - 2 * $padding
            Write-Verbose "Image dimensions for layout: Width=$($bitmap.Width) Height=$($bitmap.Height)"
            Write-Verbose "Padding=$padding MaxFontSize=$maxFontSize pt AvailableWidth=$availableWidth px"
            # GenericTypographic gives true text bounds without GDI+ whitespace padding
            $typographicFormat = [System.Drawing.StringFormat]::GenericTypographic

            if (-not [string]::IsNullOrWhiteSpace($TopText)) {
                $text = $TopText.ToUpper()
                $fontSize = $maxFontSize
                $font = New-Object System.Drawing.Font('Impact', $fontSize, [System.Drawing.FontStyle]::Bold)
                $size = $graphics.MeasureString($text, $font, [System.Drawing.PointF]::Empty, $typographicFormat)
                Write-Verbose "TopText='$text' Initial measured size: Width=$([Math]::Round($size.Width,1)) Height=$([Math]::Round($size.Height,1)) at $fontSize pt"
                $iterations = 0
                while ($size.Width -gt $availableWidth -and $fontSize -gt 8) {
                    $font.Dispose()
                    $fontSize -= 1
                    $iterations++
                    $font = New-Object System.Drawing.Font('Impact', $fontSize, [System.Drawing.FontStyle]::Bold)
                    $size = $graphics.MeasureString($text, $font, [System.Drawing.PointF]::Empty, $typographicFormat)
                }
                Write-Verbose "TopText scaling: $iterations iteration(s) fitCheck=($([Math]::Round($size.Width,1)) <= $availableWidth)"
                $x = [float][Math]::Max(($contentWidth - $size.Width) / 2, $padding)
                Write-Verbose "TopText final: fontSize=$fontSize pt textWidth=$([Math]::Round($size.Width,1)) x=$([Math]::Round($x,1)) y=$padding"
                $point = New-Object System.Drawing.PointF($x, [float]$padding)
                # Draw text as GraphicsPath: black outline first, then white fill
                $emSize = [float]($fontSize * $graphics.DpiY / 72)
                $outlineWidth = [float][Math]::Max(2, $fontSize / 8)
                $outlinePen = New-Object System.Drawing.Pen([System.Drawing.Color]::Black, $outlineWidth)
                $outlinePen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
                $textPath = New-Object System.Drawing.Drawing2D.GraphicsPath
                $textPath.AddString($text, $font.FontFamily, [int]$font.Style, $emSize, $point, $typographicFormat)
                $graphics.DrawPath($outlinePen, $textPath)
                $graphics.FillPath($whiteBrush, $textPath)
                $textPath.Dispose()
                $outlinePen.Dispose()
                $font.Dispose()
                $font = $null
            }

            if (-not [string]::IsNullOrWhiteSpace($BottomText)) {
                $text = $BottomText.ToUpper()
                $fontSize = $maxFontSize
                $font = New-Object System.Drawing.Font('Impact', $fontSize, [System.Drawing.FontStyle]::Bold)
                $size = $graphics.MeasureString($text, $font, [System.Drawing.PointF]::Empty, $typographicFormat)
                Write-Verbose "BottomText='$text' Initial measured size: Width=$([Math]::Round($size.Width,1)) Height=$([Math]::Round($size.Height,1)) at $fontSize pt"
                $iterations = 0
                while ($size.Width -gt $availableWidth -and $fontSize -gt 8) {
                    $font.Dispose()
                    $fontSize -= 1
                    $iterations++
                    $font = New-Object System.Drawing.Font('Impact', $fontSize, [System.Drawing.FontStyle]::Bold)
                    $size = $graphics.MeasureString($text, $font, [System.Drawing.PointF]::Empty, $typographicFormat)
                }
                Write-Verbose "BottomText scaling: $iterations iteration(s) fitCheck=($([Math]::Round($size.Width,1)) <= $availableWidth)"
                $x = [float][Math]::Max(($contentWidth - $size.Width) / 2, $padding)
                $y = [float]($bitmap.Height - $size.Height - $padding)
                Write-Verbose "BottomText final: fontSize=$fontSize pt textWidth=$([Math]::Round($size.Width,1)) x=$([Math]::Round($x,1)) y=$([Math]::Round($y,1))"
                $point = New-Object System.Drawing.PointF($x, $y)
                # Draw text as GraphicsPath: black outline first, then white fill
                $emSize = [float]($fontSize * $graphics.DpiY / 72)
                $outlineWidth = [float][Math]::Max(2, $fontSize / 8)
                $outlinePen = New-Object System.Drawing.Pen([System.Drawing.Color]::Black, $outlineWidth)
                $outlinePen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
                $textPath = New-Object System.Drawing.Drawing2D.GraphicsPath
                $textPath.AddString($text, $font.FontFamily, [int]$font.Style, $emSize, $point, $typographicFormat)
                $graphics.DrawPath($outlinePen, $textPath)
                $graphics.FillPath($whiteBrush, $textPath)
                $textPath.Dispose()
                $outlinePen.Dispose()
                $font.Dispose()
                $font = $null
            }

            $jpegCodec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
            $encoderParams = New-Object System.Drawing.Imaging.EncoderParameters(1)
            $encoderParams.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, 100L)

            [void] ($bitmap.Save($OutputPath, $jpegCodec, $encoderParams))
        } catch {
            Write-Verbose "$($MyInvocation.MyCommand) Operation failed: $_"
            Write-Verbose "StackTrace: $($_.ScriptStackTrace)"
            throw $_
        } finally {
            if ($null -ne $graphics) { $graphics.Dispose() }
            if ($null -ne $bitmap) { $bitmap.Dispose() }
            if ($null -ne $whiteBrush) { $whiteBrush.Dispose() }
            if ($null -ne $blackBrush) { $blackBrush.Dispose() }
            if ($null -ne $font) { $font.Dispose() }
            if ($null -ne $encoderParams) { $encoderParams.Dispose() }
        }
    }

    end {
        Write-Verbose "Completed $($MyInvocation.MyCommand)"
    }
}
#EndRegion './Private/Invoke-MemeImageModification.ps1' 209
#Region './Public/Get-MemeTemplate.ps1' -1

function Get-MemeTemplate {
    <#
    .SYNOPSIS
        Gets a list of popular meme templates from Imgflip.

    .DESCRIPTION
        Retrieves the top 100 most popular meme templates from the Imgflip API.
        Returns objects containing the meme ID, name, URL, width, and height.

    .EXAMPLE
        Get-MemeTemplate
        Returns all available meme templates.

    .EXAMPLE
        Get-MemeTemplate -Name 'Drake'
        Finds meme templates with 'Drake' in the name.

    .OUTPUTS
        PSCustomObject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [string]
        $Name
    )

    begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand)"
    }

    process {
        try {
            $uri = 'https://api.imgflip.com/get_memes'
            Write-Verbose "Fetching meme templates from $uri"

            $response = Invoke-RestMethod -Uri $uri -Method Get

            if ($response.success) {
                foreach ($meme in $response.data.memes) {
                    if ([string]::IsNullOrEmpty($Name) -or $meme.name -match $Name) {
                        [PSCustomObject]@{
                            Id       = $meme.id
                            Name     = $meme.name
                            Url      = $meme.url
                            Width    = $meme.width
                            Height   = $meme.height
                            BoxCount = $meme.box_count
                        }
                    }
                }
            } else {
                throw "Imgflip API returned an error: $($response.error_message)"
            }
        } catch {
            Write-Verbose "$($MyInvocation.MyCommand) Operation failed: $_"
            Write-Verbose "StackTrace: $($_.ScriptStackTrace)"
            throw $_
        }
    }

    end {
        Write-Verbose "Completed $($MyInvocation.MyCommand)"
    }
}
#EndRegion './Public/Get-MemeTemplate.ps1' 66
#Region './Public/New-Meme.ps1' -1

function New-Meme {
    <#
    .SYNOPSIS
        Creates a new meme by applying text to an image template.

    .DESCRIPTION
        Downloads a meme template from a URL and applies top and bottom text using System.Drawing.
        Requires Windows with PowerShell 7.4 or later (Core edition) due to System.Drawing dependencies.

    .PARAMETER Id
        The ID of the source meme template from Imgflip.

    .PARAMETER Name
        The name of the source meme template from Imgflip.

    .PARAMETER Url
        The URL of the source meme image.

    .PARAMETER TopText
        The text to display at the top of the meme.

    .PARAMETER BottomText
        The text to display at the bottom of the meme.

    .PARAMETER OutputPath
        The path where the generated meme image will be saved.
        Defaults to the current user's Desktop. The filename is derived from BottomText (or TopText
        if BottomText is not provided), with spaces replaced by underscores (e.g. meme_like_a_boss.jpg).
        Falls back to meme.jpg if neither text is provided.

    .EXAMPLE
        New-Meme -Name "Drake" -TopText "WHEN YOU WRITE" -BottomText "A POWERSHELL MODULE" -OutputPath ".\meme.jpg"
        Creates a meme using the first template matching "Drake" and saves it to meme.jpg.

    .EXAMPLE
        New-Meme -Id "181913649" -TopText "WHEN YOU WRITE" -BottomText "A POWERSHELL MODULE" -OutputPath ".\meme.jpg"
        Creates a meme using the template with ID "181913649" and saves it to meme.jpg.

    .OUTPUTS
        System.IO.FileInfo
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByName')]
    param (
        [Parameter(Mandatory, ParameterSetName = 'ById', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Id,

        [Parameter(Mandatory, ParameterSetName = 'ByName', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Mandatory, ParameterSetName = 'ByUrl', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Url,

        [Parameter()]
        [string]
        $OutputPath,

        [Parameter(Mandatory = $false)]
        [string]
        $TopText = '',

        [Parameter(Mandatory = $false)]
        [string]
        $BottomText = ''
    )

    begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand)"
    }

    process {
        try {
            if ([string]::IsNullOrEmpty($OutputPath)) {
                $desktop = [System.Environment]::GetFolderPath('Desktop')
                $textForName = if (-not [string]::IsNullOrWhiteSpace($BottomText)) { $BottomText } elseif (-not [string]::IsNullOrWhiteSpace($TopText)) { $TopText } else { 'meme' }
                $safeName = ($textForName.ToLower() -replace '[^a-z0-9]+', '_').Trim('_')
                $OutputPath = Join-Path $desktop "meme_$safeName.jpg"
                Write-Verbose "No OutputPath specified, defaulting to: $OutputPath"
            }

            $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)

            $targetUrl = $Url
            if ($PSCmdlet.ParameterSetName -eq 'ById') {
                Write-Verbose "Looking up meme template by ID: $Id"
                $template = Get-MemeTemplate | Where-Object Id -EQ $Id | Select-Object -First 1
                if (-not $template) {
                    throw "Could not find a meme template with ID '$Id'."
                }
                $targetUrl = $template.Url
            } elseif ($PSCmdlet.ParameterSetName -eq 'ByName') {
                Write-Verbose "Looking up meme template by Name: $Name"
                $template = Get-MemeTemplate -Name $Name | Select-Object -First 1
                if (-not $template) {
                    throw "Could not find a meme template matching Name '$Name'."
                }
                $targetUrl = $template.Url
            }

            if ($PSCmdlet.ShouldProcess($resolvedPath, "Create meme from $targetUrl")) {
                Write-Verbose "Downloading image from $targetUrl"

                $tempFile = [System.IO.Path]::GetTempFileName()
                try {
                    Invoke-WebRequest -Uri $targetUrl -OutFile $tempFile
                    Invoke-MemeImageModification -ImagePath $tempFile -OutputPath $resolvedPath -TopText $TopText -BottomText $BottomText
                    Get-Item -Path $resolvedPath
                } finally {
                    if (Test-Path $tempFile) {
                        Remove-Item -Path $tempFile -Force
                    }
                }
            }
        } catch {
            Write-Verbose "$($MyInvocation.MyCommand) Operation failed: $_"
            Write-Verbose "StackTrace: $($_.ScriptStackTrace)"
            throw $_
        }
    }

    end {
        Write-Verbose "Completed $($MyInvocation.MyCommand)"
    }
}
#EndRegion './Public/New-Meme.ps1' 130