lib/imagepixels.ps1
|
# imagepixels.ps1 -- read an image's pixels for `tstyles create`. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # Windows only: System.Drawing.Common is Windows-only from .NET 6 onward, and # there is no other decoder in the module. Off Windows the reader says so # rather than returning nothing. function Test-ImagePixelReaderAvailable { # True on Windows when System.Drawing loads. if ((Get-TStylesPlatform) -ne 'Windows') { return $false } try { Add-Type -AssemblyName System.Drawing -ErrorAction Stop return $true } catch { return $false } } function Get-ImagePixelsInterpolation { # The GDI+ InterpolationMode name for a downscale by $Scale (target over # source, 0-1]. Below a quarter, HighQualityBilinear: a 6000-pixel photo # cut to 96 spends seconds in bicubic for detail the palette, which only # counts colours, never looks at. Otherwise HighQualityBicubic. [OutputType([string])] param([Parameter(Mandatory)][double]$Scale) if ($Scale -lt 0.25) { return 'HighQualityBilinear' } 'HighQualityBicubic' } function Read-ImagePixels { [OutputType([int[]])] # Returns the pixels as [int] values, each 0xRRGGBB, unrolled onto the # pipeline (see the end of the function for the caller contract). Reads the first frame of a GIF, ignores # alpha (transparent pixels are drawn over black), and # scales the longest side down to at most -MaxSide before sampling. # Anything GDI+ throws while decoding, drawing or locking the image ("A # generic error occurred in GDI+.", "Out of memory." for a file it cannot # decode) is the one "Not an image TerminalStyles can read" message. param( [Parameter(Mandatory)][string]$Path, [ValidateRange(1, 4096)][int]$MaxSide = 96 ) if (-not (Test-ImagePixelReaderAvailable)) { throw 'Reading images needs Windows.' } $notImage = "Not an image TerminalStyles can read: $Path" if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw $notImage } $img = $null $small = $null $g = $null $attr = $null try { $out = $null try { $full = (Resolve-Path -LiteralPath $Path).ProviderPath $img = [System.Drawing.Image]::FromFile($full) if ($img.FrameDimensionsList.Count -gt 0) { $dim = [System.Drawing.Imaging.FrameDimension]::new($img.FrameDimensionsList[0]) if ($img.GetFrameCount($dim) -gt 1) { [void]$img.SelectActiveFrame($dim, 0) } } $w = [int]$img.Width $h = [int]$img.Height if ($w -lt 1 -or $h -lt 1) { throw $notImage } $scale = [Math]::Min(1.0, $MaxSide / [double][Math]::Max($w, $h)) $tw = [Math]::Max(1, [int][Math]::Round($w * $scale)) $th = [Math]::Max(1, [int][Math]::Round($h * $scale)) $small = [System.Drawing.Bitmap]::new($tw, $th, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) $g = [System.Drawing.Graphics]::FromImage($small) $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode](Get-ImagePixelsInterpolation -Scale $scale) $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality $g.Clear([System.Drawing.Color]::Black) # TileFlipXY so edge samples blend with the mirrored image, not with # transparent, which would darken the border over the black Clear. $attr = [System.Drawing.Imaging.ImageAttributes]::new() $attr.SetWrapMode([System.Drawing.Drawing2D.WrapMode]::TileFlipXY) $dest = [System.Drawing.Rectangle]::new(0, 0, $tw, $th) $g.DrawImage($img, $dest, 0, 0, $w, $h, [System.Drawing.GraphicsUnit]::Pixel, $attr) $rect = [System.Drawing.Rectangle]::new(0, 0, $tw, $th) $data = $small.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) try { $argb = [int[]]::new($tw * $th) if ($data.Stride -ne $tw * 4) { # Rows are padded: copy row by row. $row = [int[]]::new($tw) for ($y = 0; $y -lt $th; $y++) { [System.Runtime.InteropServices.Marshal]::Copy([IntPtr]($data.Scan0.ToInt64() + [int64]$y * $data.Stride), $row, 0, $tw) [Array]::Copy($row, 0, $argb, $y * $tw, $tw) } } else { [System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $argb, 0, $argb.Length) } } finally { $small.UnlockBits($data) } $out = [int[]]::new($argb.Length) for ($i = 0; $i -lt $argb.Length; $i++) { $out[$i] = $argb[$i] -band 0xFFFFFF } } catch { # Decode, draw, lock and copy: whatever GDI+ says, the user is # told the one thing that is true -- this file could not be read. throw $notImage } # Emit the elements (no unary comma): the pipeline unrolls them, so the # caller gets a flat sequence of [int] whether or not it wraps the call # in @(). Callers that need an array use @(Read-ImagePixels ...); a bare # assignment is an [int[]] except for a 1x1 image, which arrives as one # [int] and still binds to an [int[]] parameter. $out } finally { if ($attr) { $attr.Dispose() } if ($g) { $g.Dispose() } if ($small) { $small.Dispose() } if ($img) { $img.Dispose() } } } |