Screenshot.psm1
|
# This is a locally sourced Imports file for local development. # It can be imported by the psm1 in local development to add script level variables. # It will merged in the build process. This is for local development only. # region script variables # $script:resourcePath = "$PSScriptRoot\Resources" function Get-WindowLocation { [CmdletBinding()] param( [Parameter(Position = 0, Mandatory = $true)] [string]$WindowTitle ) Add-Type @" using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; namespace Screenshot { public class Window { [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); // From an unknown user on Reddit: https://www.reddit.com/r/PowerShell/comments/2llb4u/comment/cm0e8fi/ private class unmanaged { // FindWindowByCaption [DllImport("user32.dll", EntryPoint="FindWindow", SetLastError = true)] internal static extern IntPtr FindWindowByCaption(IntPtr ZeroOnly, string lpWindowName); [DllImport("user32.dll")] internal static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); [DllImport("user32.dll")] internal static extern bool IsWindowVisible(IntPtr hWnd); } // FindWindowByCaption public static IntPtr FindWindowByCaption(string Title) { return unmanaged.FindWindowByCaption(IntPtr.Zero, Title); } private static string GetWindowText(IntPtr hWnd) { int length = unmanaged.GetWindowTextLength(hWnd); if (length == 0) { return string.Empty; } StringBuilder builder = new StringBuilder(length + 1); unmanaged.GetWindowText(hWnd, builder, builder.Capacity); return builder.ToString(); } // Enumerate visible top-level windows and return the handle of the first one // whose title matches exactly, falling back to a partial (contains) match. // This is more robust than FindWindow for modern (UWP-hosted) windows such // as the Windows 11 Notepad. public static IntPtr FindWindowByPartialCaption(string Title) { IntPtr exactMatch = IntPtr.Zero; IntPtr partialMatch = IntPtr.Zero; unmanaged.EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) { if (!unmanaged.IsWindowVisible(hWnd)) { return true; } string text = GetWindowText(hWnd); if (string.IsNullOrEmpty(text)) { return true; } if (string.Equals(text, Title, StringComparison.OrdinalIgnoreCase)) { exactMatch = hWnd; return false; // stop enumeration on exact match } if (partialMatch == IntPtr.Zero && text.IndexOf(Title, StringComparison.OrdinalIgnoreCase) >= 0) { partialMatch = hWnd; } return true; }, IntPtr.Zero); return exactMatch != IntPtr.Zero ? exactMatch : partialMatch; } } public struct RECT { public int Left; // x position of upper-left corner public int Top; // y position of upper-left corner public int Right; // x position of lower-right corner public int Bottom; // y position of lower-right corner } } "@ # Fast path: exact match via FindWindow. $WindowHandle = [Screenshot.Window]::FindWindowByCaption($WindowTitle) # Fallback: enumerate visible windows for an exact or partial title match. # Handles modern (UWP-hosted) windows such as the Windows 11 Notepad. if ($WindowHandle -eq [IntPtr]::Zero) { $WindowHandle = [Screenshot.Window]::FindWindowByPartialCaption($WindowTitle) } if ($WindowHandle -eq [IntPtr]::Zero) { throw "Failed to find a window with the title '$WindowTitle'." } $Rectangle = [Screenshot.RECT]::New() if ([Screenshot.Window]::GetWindowRect($WindowHandle, [ref]$Rectangle)) { return $Rectangle } else { throw "Failed to get window coordinates for '$WindowTitle'." } } function Set-DPIAware { <# .SYNOPSIS Makes the current process DPI aware so screen coordinates are reported in physical pixels. .DESCRIPTION When a screen scaling factor (e.g. 200%) is used, a process that is not DPI aware receives virtualized (scaled down) coordinates and dimensions from the operating system. This causes screenshots to be captured at the wrong size and position. Set-DPIAware flags the current process as DPI aware via the Win32 API. It prefers the modern per-monitor aware context and falls back to the legacy system DPI aware call on older systems. DPI awareness can only be set once per process, so subsequent calls are effectively ignored. .OUTPUTS None .NOTES Only has an effect on Windows. #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] param() # DPI awareness is a Windows-only concept. if (-not ($IsWindows -or $env:OS -eq 'Windows_NT')) { return } Add-Type @" using System; using System.Runtime.InteropServices; namespace Screenshot { public static class DPI { [DllImport("user32.dll")] private static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport("user32.dll")] private static extern bool SetProcessDPIAware(); // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 private static readonly IntPtr PerMonitorAwareV2 = new IntPtr(-4); public static void Enable() { try { if (SetProcessDpiAwarenessContext(PerMonitorAwareV2)) { return; } } catch (EntryPointNotFoundException) { // SetProcessDpiAwarenessContext is not available on older Windows versions. } SetProcessDPIAware(); } } } "@ -ErrorAction SilentlyContinue try { [Screenshot.DPI]::Enable() } catch { Write-Verbose -Message ("Unable to set process DPI awareness: {0}" -f $_.Exception.Message) } } function New-Screenshot { <# .EXTERNALHELP Screenshot-help.xml #> [CmdletBinding(DefaultParameterSetName = 'Coordinates', SupportsShouldProcess = $true)] param( [Parameter(ParameterSetName = 'Coordinates', Position = 0)] [Parameter(ParameterSetName = 'CoordinatesWithSize', Position = 0)] [int]$X = 0, [Parameter(ParameterSetName = 'Coordinates', Position = 1)] [Parameter(ParameterSetName = 'CoordinatesWithSize', Position = 1)] [int]$Y = 0, [Parameter(ParameterSetName = 'Coordinates', Position = 2)] [int]$Width, [Parameter(ParameterSetName = 'Coordinates', Position = 3)] [int]$Height, [Parameter(ParameterSetName = 'WindowTitle', Position = 0)] [string]$WindowTitle, [Parameter(ParameterSetName = 'WindowTitle', Position = 1)] [Parameter(ParameterSetName = 'Coordinates', Position = 4)] [Parameter(ParameterSetName = 'CoordinatesWithSize', Position = 2)] [string]$Path, [Parameter(ParameterSetName = 'WindowTitle', Position = 2)] [Parameter(ParameterSetName = 'Coordinates', Position = 5)] [Parameter(ParameterSetName = 'CoordinatesWithSize', Position = 3)] [string]$FileName = "Screenshot_{0}.jpg" -f (Get-Date -Format "yyyyMMdd_HHmmss") ) Add-Type -AssemblyName System.Windows.Forms, System.Drawing # Ensure coordinates and dimensions are reported in physical pixels when a # screen scaling factor (e.g. 200%) is used. See issue #3. Set-DPIAware switch ($PSCmdlet.ParameterSetName) { "Coordinates" { if ($Width -eq 0) { $Width = [System.Windows.Forms.SystemInformation]::VirtualScreen.Width if ($X -gt 0) { $Width = $Width - $X } } if ($Height -eq 0) { $Height = [System.Windows.Forms.SystemInformation]::VirtualScreen.Height if ($Y -gt 0) { $Height = $Height - $Y } } break } "WindowTitle" { $Coordinates = Get-WindowLocation -WindowTitle $WindowTitle $X = $Coordinates.Left $Y = $Coordinates.Top $Width = $Coordinates.Right - $Coordinates.Left $Height = $Coordinates.Bottom - $Coordinates.Top break } } $Bitmap = [System.Drawing.Bitmap]::new($Width, $Height) $Graphic = [System.Drawing.Graphics]::FromImage($bitmap) $Graphic.CopyFromScreen($X, $Y, 0, 0, [System.Drawing.Size]::new($Width, $Height)) #region Screenshot Destination Path if ([string]::IsNullOrEmpty($Path)) { $Path = Get-Location # Fallback to TEMP if current location is root of C:\ and user is not admin to avoid "Access Denied" error # not really necessary, but the previous behavior annoyed me lol if ( $Path.ToString() -eq "C:\" -and ([Security.Principal.WindowsIdentity]::GetCurrent().Groups -notcontains 'S-1-5-32-544')) { $Path = $env:TEMP } } $DestinationPath = Join-Path -Path $Path -ChildPath $FileName #endregion Screenshot Destination Path #region Save Screenshot if ($PSCmdlet.ShouldProcess($DestinationPath, ("Saving Screenshot to path: '{0}'" -f $DestinationPath))) { try { $Bitmap.Save($DestinationPath, [System.Drawing.Imaging.ImageFormat]::Jpeg) Get-Item -Path $DestinationPath } catch { throw "Failed to save screenshot to path: '{0}'. Error: {1}" -f $DestinationPath, $_.Exception.Message } } #endregion Save Screenshot } |