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