Tests/TestHelpers.ps1

Add-Type -AssemblyName System.IO.Compression

function New-MinimalPng {
    <#
    .SYNOPSIS
        Builds a minimal valid RGBA PNG from known pixel data.

    .PARAMETER Width
        Image width in pixels.

    .PARAMETER Height
        Image height in pixels.

    .PARAMETER RgbaData
        Flat byte array: R,G,B,A per pixel, row by row, top to bottom.

    .OUTPUTS
        byte[] containing a valid PNG file.
    #>

    param(
        [int]$Width,
        [int]$Height,
        [byte[]]$RgbaData
    )

    $result = [System.Collections.Generic.List[byte]]::new()

    # PNG signature
    $result.AddRange([byte[]]@(137, 80, 78, 71, 13, 10, 26, 10))

    # IHDR chunk (13 bytes data)
    $ihdrData = [byte[]]::new(13)
    $ihdrData[0] = ($Width -shr 24) -band 0xFF
    $ihdrData[1] = ($Width -shr 16) -band 0xFF
    $ihdrData[2] = ($Width -shr 8) -band 0xFF
    $ihdrData[3] = $Width -band 0xFF
    $ihdrData[4] = ($Height -shr 24) -band 0xFF
    $ihdrData[5] = ($Height -shr 16) -band 0xFF
    $ihdrData[6] = ($Height -shr 8) -band 0xFF
    $ihdrData[7] = $Height -band 0xFF
    $ihdrData[8] = 8   # bit depth
    $ihdrData[9] = 6   # color type: RGBA
    $ihdrData[10] = 0  # compression
    $ihdrData[11] = 0  # filter
    $ihdrData[12] = 0  # interlace

    $ihdrLength = [BitConverter]::GetBytes([int]13)
    [Array]::Reverse($ihdrLength)
    $result.AddRange($ihdrLength)
    $result.AddRange([byte[]]@(73, 72, 68, 82))  # "IHDR"
    $result.AddRange($ihdrData)
    $result.AddRange([byte[]]@(0, 0, 0, 0))  # CRC placeholder

    # Build scanline data with filter byte 0 (None) per row
    $stride = $Width * 4
    $rawScanlines = [byte[]]::new($Height * ($stride + 1))
    for ($row = 0; $row -lt $Height; $row++) {
        $rawOffset = $row * ($stride + 1)
        $rawScanlines[$rawOffset] = 0  # filter type None
        $srcOffset = $row * $stride
        [Array]::Copy($RgbaData, $srcOffset, $rawScanlines, $rawOffset + 1, $stride)
    }

    # Compress with deflate, wrap in zlib
    $memStream = [System.IO.MemoryStream]::new()
    $deflateStream = [System.IO.Compression.DeflateStream]::new(
        $memStream,
        [System.IO.Compression.CompressionLevel]::Optimal,
        $true
    )
    $deflateStream.Write($rawScanlines, 0, $rawScanlines.Length)
    $deflateStream.Close()
    $deflatedBytes = $memStream.ToArray()
    $memStream.Dispose()

    # IDAT chunk: zlib header + deflated data
    $idatPayload = [System.Collections.Generic.List[byte]]::new()
    $idatPayload.AddRange([byte[]]@(0x78, 0x9C))
    $idatPayload.AddRange($deflatedBytes)
    $idatLength = [BitConverter]::GetBytes([int]$idatPayload.Count)
    [Array]::Reverse($idatLength)
    $result.AddRange($idatLength)
    $result.AddRange([byte[]]@(73, 68, 65, 84))  # "IDAT"
    $result.AddRange($idatPayload)
    $result.AddRange([byte[]]@(0, 0, 0, 0))  # CRC placeholder

    # IEND chunk
    $result.AddRange([byte[]]@(0, 0, 0, 0))  # length 0
    $result.AddRange([byte[]]@(73, 69, 78, 68))  # "IEND"
    $result.AddRange([byte[]]@(0, 0, 0, 0))  # CRC placeholder

    return , $result.ToArray()
}

function New-MinimalPiskelJson {
    <#
    .SYNOPSIS
        Builds a minimal valid .piskel JSON string from layer PNG data.

    .PARAMETER Name
        Sprite name.

    .PARAMETER Width
        Image width.

    .PARAMETER Height
        Image height.

    .PARAMETER LayerNames
        Array of layer names.

    .PARAMETER LayerPngBytes
        Array of byte arrays, each a valid PNG file.

    .OUTPUTS
        String containing valid .piskel JSON.
    #>

    param(
        [string]$Name,
        [int]$Width,
        [int]$Height,
        [string[]]$LayerNames,
        [byte[][]]$LayerPngBytes
    )

    $layerJsonList = [System.Collections.Generic.List[string]]::new()

    for ($i = 0; $i -lt $LayerNames.Count; $i++) {
        $base64 = [Convert]::ToBase64String($LayerPngBytes[$i])
        $layerObj = @{
            name       = $LayerNames[$i]
            opacity    = 1
            frameCount = 1
            chunks     = @(
                @{
                    layout    = @(, @(0))
                    base64PNG = "data:image/png;base64,$base64"
                }
            )
        }
        $layerJsonList.Add(($layerObj | ConvertTo-Json -Depth 10 -Compress))
    }

    $piskelObj = @{
        modelVersion = 2
        piskel       = @{
            name        = $Name
            description = ''
            fps         = 12
            height      = $Height
            width       = $Width
            layers      = $layerJsonList.ToArray()
        }
    }

    return ($piskelObj | ConvertTo-Json -Depth 10 -Compress)
}

function New-MinimalAseFile {
    <#
    .SYNOPSIS
        Builds a minimal valid .ase binary from known pixel data.

    .DESCRIPTION
        Constructs a valid Aseprite file with a 128-byte header, one frame,
        and one layer chunk + one compressed cel chunk per layer.
        All cels are full-canvas at position (0,0), 32bpp RGBA.

    .PARAMETER Width
        Image width.

    .PARAMETER Height
        Image height.

    .PARAMETER LayerNames
        Array of layer names.

    .PARAMETER LayerRgbaData
        Array of flat byte arrays, one per layer (R,G,B,A per pixel).

    .OUTPUTS
        byte[] containing a valid .ase file.
    #>

    param(
        [int]$Width,
        [int]$Height,
        [string[]]$LayerNames,
        [byte[][]]$LayerRgbaData
    )

    $frameData = [System.Collections.Generic.List[byte]]::new()
    $chunkCount = $LayerNames.Count * 2

    # frame header placeholder (16 bytes)
    $frameData.AddRange([byte[]]@(0, 0, 0, 0))                             # frame size placeholder
    $frameData.AddRange([BitConverter]::GetBytes([uint16]0xF1FA))           # magic
    $frameData.AddRange([BitConverter]::GetBytes([uint16]$chunkCount))      # old chunk count
    $frameData.AddRange([BitConverter]::GetBytes([uint16]100))              # frame duration
    $frameData.AddRange([byte[]]@(0, 0))                                   # future
    $frameData.AddRange([BitConverter]::GetBytes([uint32]0))                # new chunk count (0 = use old)

    for ($li = 0; $li -lt $LayerNames.Count; $li++) {
        $nameBytes = [System.Text.Encoding]::UTF8.GetBytes($LayerNames[$li])

        # layer chunk (0x2004)
        $layerChunk = [System.Collections.Generic.List[byte]]::new()
        $layerChunk.AddRange([byte[]]@(0, 0, 0, 0))                        # chunk size placeholder
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]0x2004))       # chunk type
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]1))            # flags: visible
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]0))            # layer type: normal
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]0))            # child level
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]0))            # default width (ignored)
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]0))            # default height (ignored)
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]0))            # blend mode: normal
        $layerChunk.Add([byte]255)                                          # opacity
        $layerChunk.AddRange([byte[]]@(0, 0, 0))                            # future
        $layerChunk.AddRange([BitConverter]::GetBytes([uint16]$nameBytes.Length))
        $layerChunk.AddRange($nameBytes)

        $chunkSizeBytes = [BitConverter]::GetBytes([uint32]$layerChunk.Count)
        $layerChunk[0] = $chunkSizeBytes[0]
        $layerChunk[1] = $chunkSizeBytes[1]
        $layerChunk[2] = $chunkSizeBytes[2]
        $layerChunk[3] = $chunkSizeBytes[3]
        $frameData.AddRange($layerChunk)

        # cel chunk (0x2005) - compressed image
        $celChunk = [System.Collections.Generic.List[byte]]::new()
        $celChunk.AddRange([byte[]]@(0, 0, 0, 0))                          # chunk size placeholder
        $celChunk.AddRange([BitConverter]::GetBytes([uint16]0x2005))         # chunk type
        $celChunk.AddRange([BitConverter]::GetBytes([uint16]$li))            # layer index
        $celChunk.AddRange([BitConverter]::GetBytes([int16]0))               # x position
        $celChunk.AddRange([BitConverter]::GetBytes([int16]0))               # y position
        $celChunk.Add([byte]255)                                            # opacity
        $celChunk.AddRange([BitConverter]::GetBytes([uint16]2))              # cel type: compressed image
        $celChunk.AddRange([BitConverter]::GetBytes([int16]0))               # z-index
        $celChunk.AddRange([byte[]]@(0, 0, 0, 0, 0))                        # future
        $celChunk.AddRange([BitConverter]::GetBytes([uint16]$Width))          # cel width
        $celChunk.AddRange([BitConverter]::GetBytes([uint16]$Height))         # cel height

        # compress RGBA data with zlib
        $memStream = [System.IO.MemoryStream]::new()
        $deflateStream = [System.IO.Compression.DeflateStream]::new(
            $memStream,
            [System.IO.Compression.CompressionLevel]::Optimal,
            $true
        )
        $deflateStream.Write($LayerRgbaData[$li], 0, $LayerRgbaData[$li].Length)
        $deflateStream.Close()
        $deflatedBytes = $memStream.ToArray()
        $memStream.Dispose()

        $zlibData = [System.Collections.Generic.List[byte]]::new()
        $zlibData.AddRange([byte[]]@(0x78, 0x9C))
        $zlibData.AddRange($deflatedBytes)
        $celChunk.AddRange($zlibData)

        $chunkSizeBytes = [BitConverter]::GetBytes([uint32]$celChunk.Count)
        $celChunk[0] = $chunkSizeBytes[0]
        $celChunk[1] = $chunkSizeBytes[1]
        $celChunk[2] = $chunkSizeBytes[2]
        $celChunk[3] = $chunkSizeBytes[3]
        $frameData.AddRange($celChunk)
    }

    # set frame size
    $frameSizeBytes = [BitConverter]::GetBytes([uint32]$frameData.Count)
    $frameData[0] = $frameSizeBytes[0]
    $frameData[1] = $frameSizeBytes[1]
    $frameData[2] = $frameSizeBytes[2]
    $frameData[3] = $frameSizeBytes[3]

    # 128-byte header
    $header = [byte[]]::new(128)
    $fileSize = 128 + $frameData.Count
    [Array]::Copy([BitConverter]::GetBytes([uint32]$fileSize), 0, $header, 0, 4)
    [Array]::Copy([BitConverter]::GetBytes([uint16]0xA5E0), 0, $header, 4, 2)
    [Array]::Copy([BitConverter]::GetBytes([uint16]1), 0, $header, 6, 2)         # 1 frame
    [Array]::Copy([BitConverter]::GetBytes([uint16]$Width), 0, $header, 8, 2)
    [Array]::Copy([BitConverter]::GetBytes([uint16]$Height), 0, $header, 10, 2)
    [Array]::Copy([BitConverter]::GetBytes([uint16]32), 0, $header, 12, 2)       # 32bpp RGBA
    [Array]::Copy([BitConverter]::GetBytes([uint32]1), 0, $header, 14, 4)        # flags: layer opacity valid
    [Array]::Copy([BitConverter]::GetBytes([uint16]100), 0, $header, 18, 2)      # speed
    $header[34] = 1  # pixel width
    $header[35] = 1  # pixel height
    [Array]::Copy([BitConverter]::GetBytes([uint16]16), 0, $header, 40, 2)       # grid width
    [Array]::Copy([BitConverter]::GetBytes([uint16]16), 0, $header, 42, 2)       # grid height

    $result = [System.Collections.Generic.List[byte]]::new()
    $result.AddRange($header)
    $result.AddRange($frameData)

    return , $result.ToArray()
}