NikonExif.psm1
|
<#
NikonExif - reads EXIF metadata and shutter count from JPG and NEF files. Requirements: * Windows PowerShell 5.1 or PowerShell 7+ * Supported extensions: .jpg, .jpeg, .nef Example: Import-Module NikonExif $Exif = Get-Exif -Path 'D:\Temp\DSC_5956.JPG' $Exif.Make $Exif.Model $Exif.ShutterCount #> Set-StrictMode -Version Latest function Get-Exif { <# .SYNOPSIS Reads EXIF metadata and the Nikon shutter count from a JPG or NEF file. .DESCRIPTION Parses the EXIF block directly from the file bytes. For JPG the APP1 segment is located; for NEF the file is treated as a TIFF container. Tag values are returned as strings through a single object, one property per tag. ShutterCount is read from the Nikon MakerNote and is only available for Nikon bodies that record it. .PARAMETER Path Path to the image file. Supported extensions: .jpg, .jpeg, .nef. .EXAMPLE Get-Exif -Path 'D:\Temp\DSC_5956.JPG' Make : NIKON CORPORATION Model : NIKON D90 ShutterCount : 24531 .EXAMPLE (Get-ChildItem D:\Photos\*.NEF).ForEach({ Get-Exif $_.FullName }) Reads EXIF from every NEF file in the folder. .INPUTS None. This function does not accept pipeline input. .OUTPUTS PSCustomObject with one property per EXIF tag, plus ShutterCount. A tag missing from the file yields $null, but the property is always present. .NOTES Returns $null and writes a message to the host if the file does not exist, has an unsupported extension, or contains no readable EXIF block. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, HelpMessage = 'Path to a JPG or NEF file')] [string]$Path ) # Strip whitespace and quotes in case the path was typed at the prompt $Path = $Path.Trim().Trim('"').Trim("'").Trim() # Validate the extension $Allowed = '.jpg', '.jpeg', '.nef' $ext = [IO.Path]::GetExtension($Path).ToLowerInvariant() if ($ext -notin $Allowed) { Write-Host 'The script supports .jpg, .jpeg and .nef extensions' -ForegroundColor Red return $null } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Write-Host "File does not exist: $Path" -ForegroundColor Red return $null } # Tags in the required order: Id -> resulting property name # (an array of pairs rather than [ordered], because OrderedDictionary treats [int] keys as positions) $wanted = @( @{ Id = 0x010F; Name = 'Make' } @{ Id = 0x0110; Name = 'Model' } @{ Id = 0xA433; Name = 'LensMake' } @{ Id = 0x0131; Name = 'Software' } @{ Id = 0x829A; Name = 'ExposureTime' } @{ Id = 0x829D; Name = 'FNumber' } @{ Id = 0x8822; Name = 'ExposureProgram' } @{ Id = 0x8827; Name = 'ISOSpeedRatings' } @{ Id = 0xA405; Name = 'FocalLengthIn35mmFilm' } @{ Id = 0xA432; Name = 'LensSpecification' } @{ Id = 0x9003; Name = 'DateTimeOriginal' } @{ Id = 0x9004; Name = 'DateTimeDigitized' } @{ Id = 0xA431; Name = 'BodySerialNumber' } @{ Id = 0xA435; Name = 'LensSerialNumber' } ) # Use a period as the decimal separator (3.2, not 3,2 under a Russian locale) $inv = [Globalization.CultureInfo]::InvariantCulture # A full path is required because .NET does not know about the PowerShell current directory $fullPath = (Get-Item -LiteralPath $Path).FullName try { $bytes = [IO.File]::ReadAllBytes($fullPath) } catch { Write-Host "Failed to read the file: $($_.Exception.Message)" -ForegroundColor Red return $null } # --- Locate the start of the TIFF block that carries EXIF --- $tiff = -1 if ($bytes.Length -ge 4 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xD8) { # JPEG: walk the segments looking for APP1 (0xFFE1) with the 'Exif' signature $pos = 2 while ($pos + 4 -le $bytes.Length -and $bytes[$pos] -eq 0xFF) { $marker = $bytes[$pos + 1] if ($marker -eq 0xDA -or $marker -eq 0xD9) { break } # image data starts here $segLen = ([int]$bytes[$pos + 2] -shl 8) -bor $bytes[$pos + 3] if ($marker -eq 0xE1 -and $pos + 10 -le $bytes.Length -and [Text.Encoding]::ASCII.GetString($bytes, $pos + 4, 4) -eq 'Exif') { $tiff = $pos + 10 break } $pos += 2 + $segLen } } elseif ($bytes.Length -ge 8 -and (($bytes[0] -eq 0x49 -and $bytes[1] -eq 0x49) -or ($bytes[0] -eq 0x4D -and $bytes[1] -eq 0x4D))) { $tiff = 0 # NEF is a TIFF container; EXIF starts at the very first byte } if ($tiff -lt 0) { Write-Host "EXIF not found or the format is not supported: $Path" -ForegroundColor Red return $null } # 'II' = little-endian (Intel), 'MM' = big-endian (Motorola) $le = $bytes[$tiff] -eq 0x49 # --- Read numbers taking byte order into account --- function Get-Chunk([long]$o, [int]$size) { $c = [byte[]]::new($size) [Array]::Copy($bytes, $o, $c, 0, $size) if ($le -ne [BitConverter]::IsLittleEndian) { [Array]::Reverse($c) } , $c } function U16([long]$o) { [BitConverter]::ToUInt16((Get-Chunk $o 2), 0) } function U32([long]$o) { [BitConverter]::ToUInt32((Get-Chunk $o 4), 0) } function I32([long]$o) { [BitConverter]::ToInt32((Get-Chunk $o 4), 0) } # Size of a single element for each TIFF type $typeSize = @{ 1 = 1; 2 = 1; 3 = 2; 4 = 4; 5 = 8; 7 = 1; 9 = 4; 10 = 8 } $tags = @{} # --- Parse the tag directory (IFD) --- function Read-Ifd([long]$ifdOffset) { $start = $tiff + $ifdOffset if ($start + 2 -gt $bytes.Length) { return } $count = U16 $start for ($i = 0; $i -lt $count; $i++) { $e = $start + 2 + $i * 12 if ($e + 12 -gt $bytes.Length) { break } $tag = [int](U16 $e) $type = [int](U16 ($e + 2)) $n = [long](U32 ($e + 4)) if (-not $typeSize.ContainsKey($type)) { continue } $total = $n * $typeSize[$type] # Up to 4 bytes the value is stored inline in the entry; otherwise the entry holds an offset $data = if ($total -le 4) { $e + 8 } else { $tiff + (U32 ($e + 8)) } if ($data + $total -gt $bytes.Length) { continue } $tags[$tag] = @{ Type = $type; Count = $n; Offset = $data } } } # --- Format rational values --- function Format-Rational([int]$tagId, [double]$num, [double]$den) { if ($den -eq 0) { return 'n/a' } # Show the exposure time as 1/100 if ($tagId -eq 0x829A -and $num -gt 0 -and $num -lt $den) { return '1/' + [math]::Round($den / $num).ToString($inv) } [math]::Round($num / $den, 2).ToString($inv) } function Get-TagValue([int]$tagId) { $t = $tags[$tagId] $o = $t.Offset $n = [int]$t.Count if ($n -eq 0) { return '' } switch ($t.Type) { 2 { return [Text.Encoding]::ASCII.GetString($bytes, $o, $n).Trim([char]0, ' ') } 3 { $vals = for ($k = 0; $k -lt $n; $k++) { U16 ($o + 2 * $k) } } 4 { $vals = for ($k = 0; $k -lt $n; $k++) { U32 ($o + 4 * $k) } } 9 { $vals = for ($k = 0; $k -lt $n; $k++) { I32 ($o + 4 * $k) } } 5 { $vals = for ($k = 0; $k -lt $n; $k++) { Format-Rational $tagId (U32 ($o + 8 * $k)) (U32 ($o + 8 * $k + 4)) } } 10 { $vals = for ($k = 0; $k -lt $n; $k++) { Format-Rational $tagId (I32 ($o + 8 * $k)) (I32 ($o + 8 * $k + 4)) } } default { $vals = $bytes[$o..($o + $n - 1)] } } ($vals | ForEach-Object { "$_" }) -join ', ' } # --- ShutterCount from the Nikon MakerNote (tag 0x927C -> nested tag 0x00A7) --- # MakerNote layout: 'Nikon\0' + version (4 bytes), followed by its own TIFF header function Get-ShutterCount { if (-not $tags.ContainsKey(0x927C)) { return $null } $mn = $tags[0x927C] $o = [long]$mn.Offset if ($mn.Count -lt 18 -or [Text.Encoding]::ASCII.GetString($bytes, $o, 5) -ne 'Nikon') { return $null } $base = $o + 10 # start of the MakerNote TIFF header $mle = $bytes[$base] -eq 0x49 # its own byte order: II or MM function MU([long]$p, [int]$size) { $c = [byte[]]::new($size) [Array]::Copy($bytes, $p, $c, 0, $size) if ($mle -ne [BitConverter]::IsLittleEndian) { [Array]::Reverse($c) } if ($size -eq 2) { [BitConverter]::ToUInt16($c, 0) } else { [BitConverter]::ToUInt32($c, 0) } } $ifd = $base + (MU ($base + 4) 4) if ($ifd + 2 -gt $bytes.Length) { return $null } $count = MU $ifd 2 for ($i = 0; $i -lt $count; $i++) { $e = $ifd + 2 + 12 * $i if ($e + 12 -gt $bytes.Length) { break } if ((MU $e 2) -eq 0x00A7) { return MU ($e + 8) 4 } # LONG, value stored inline in the entry } return $null } # IFD0: Make, Model, Software + pointer to the EXIF directory (0x8769) Read-Ifd (U32 ($tiff + 4)) if ($tags.ContainsKey(0x8769)) { Read-Ifd (U32 $tags[0x8769].Offset) } # --- Build the result: one property per tag --- $props = [ordered]@{} foreach ($w in $wanted) { # A tag missing from the file yields $null, but the property still exists $props[$w.Name] = if ($tags.ContainsKey($w.Id)) { Get-TagValue $w.Id } else { $null } } # Number of frames taken (Nikon only) $shutter = Get-ShutterCount $props['ShutterCount'] = if ($null -ne $shutter) { "$shutter" } else { $null } [pscustomobject]$props } Export-ModuleMember -Function Get-Exif |