C#手动解析读取JPG图片中,Exif数据的拍摄日期(DateTimeOriginal),不依赖第三方库与Windows平台实现

System.Drawing.Image 内置了读取EXIF 属性项(PropertyItem )的能力------但从**.NET6** 起,System.Drawing.Common 仅在Windows上受支持。

在不引入第三方库的情况下,手动实现了一个简单的拍摄日期(DateTimeOriginal )解析器,主要是为了在Unity中使用,测试可运行的实现如下:

cs 复制代码
/// <summary>
/// 简单的EXIF解析器,仅支持JPEG格式,且只读取DateTimeOriginal。
/// </summary>
private static class ExifReader
{
    public static DateTime? GetDateTimeOriginal(string filePath, string fileName)
    {
        using var fs     = File.OpenRead(filePath);
        using var reader = new BinaryReader(fs);

        // 验证JPEG SOI段标记:0xFF 0xD8(2字节)
        if (reader.ReadByte() != 0xFF || reader.ReadByte() != 0xD8)
        {
            return null;
        }

        // 遍历JPEG段,找到APP1段标记:0xFF 0xE1(2字节)
        while (fs.Position < fs.Length)
        {
            if (reader.ReadByte() != 0xFF)
            {
                continue;
            }  

            var marker = reader.ReadByte();

            // 找到APP1段,其中就是EXIF数据
            if (marker == 0xE1)
            {
                // 读取长度(2字节)
                var segLen   = ExifReader.ReadBigEndianUInt16(reader);
                // segLen包含长度字节,减去后是EXIF长度
                var exifData = reader.ReadBytes(segLen - 2);
                return ExifReader.ParseExifDateTimeOriginal(exifData, fileName);
            }

            // 跳过其他段
            if (marker == 0xDA) 
            {
                // SOS之后是压缩数据,不再有段
                break; 
            }

            var len = ExifReader.ReadBigEndianUInt16(reader);
            fs.Position += len - 2;
        }

        return null;
    }


    /// <summary>
    /// 简化处理:直接搜索DateTimeOriginal的Tag值。
    /// 完整实现需要:解析TIFF IFD结构,此处略。
    /// </summary>
    private static DateTime? ParseExifDateTimeOriginal(byte[] data, string fileName)
    {
        // EXIF头 + TIFF头 = 14字节
        // EXIF头:
        //     Exif\0\0     (6字节)
        // TIFF头:
        //     字节序标记II/MM(2字节)
        //     TIFF魔数0x002A(2字节)
        //     第一个IFD的偏移(4字节),从data[10]开始,相对于TIFF头data[6]的偏移
        if (data.Length < 14 || data[0] != 'E' || data[1] != 'x' || data[2] != 'i' || data[3] != 'f')
        {
            // 不合法的EXIF头
            return null;
        }

        // data[6..7]: II小端, MM大端
        bool isLittleEndian;

        if (data[6] == 0x49 && data[7] == 0x49)
        {
            isLittleEndian = true;
        }
        else if (data[6] == 0x4D && data[7] == 0x4D)
        {
            isLittleEndian = false;
        }   
        else
        {
            // 不合法的TIFF头
            return null;
        }
        
        // IFD结构:[条目数 2字节][条目1 12字节][条目2 12字节]...[下一个IFD偏移 4字节]
        // 条目结构:Tag(2) + Type(2) + Count(4) + ValueOffset(4)
        // IFD每个条目的最大访问索引是i + 11,找到某个条目时,必须保证可以访问data[i + 11]
        var len = data.Length - 11; 

        // 按顺序盲搜DateTimeOriginal的Tag(0x9003),这里并没有按照IFD结构搜索Tag
        for (var i = 0; i < len; ++i)
        {
            // 搜索Tag的字节序,取决于TIFF头的大小端标识
            var found = isLittleEndian ? (data[i] == 0x03 && data[i + 1] == 0x90)
                                        : (data[i] == 0x90 && data[i + 1] == 0x03);

            if (found == false) 
            {
                continue;
            }

            // DateTimeOriginal的Type固定是2(ASCII 字符串)
            var isTypeASCII = isLittleEndian ? data[i + 2] == 0x02 && data[i + 3] == 0x00
                                                : data[i + 2] == 0x00 && data[i + 3] == 0x02;

            if (isTypeASCII == false)
            {
                continue;
            }                                                   

            var count = ExifReader.ReadInt32(data, i + 4, isLittleEndian);

            // count固定是20,因为"YYYY:MM:DD HH:MM:SS\0"刚好就是20字节
            if (count != 20)
            {
                Debug.LogError($"Image = {fileName}, count = {count}");
                continue;  
            }

            var offset    = ExifReader.ReadInt32(data, i + 8, isLittleEndian);
            // offset是相对于,TIFF头data[6]的偏移
            var absOffset = 6 + offset;

            if (absOffset + count <= data.Length)
            {
                string rawDateTime;

                try
                {
                    rawDateTime = Encoding.ASCII.GetString(data, absOffset, count).TrimEnd('\0');
                }
                catch(Exception e)
                {
                    Debug.LogError($"{e.Message}, Image = {fileName}");
                    return null;
                }
                
                return ExifReader.TryParseDateTime(rawDateTime);
            }
        }

        return null;
    }


    private static ushort ReadBigEndianUInt16(BinaryReader reader)
    {
        var high = reader.ReadByte();
        var low  = reader.ReadByte();

        return (ushort) ((high << 8) | low);
    }


    private static int ReadInt32(byte[] data, int offset, bool isLittleEndian)
    {
        var span = data.AsSpan(offset);
        return isLittleEndian ? BinaryPrimitives.ReadInt32LittleEndian(span) : BinaryPrimitives.ReadInt32BigEndian(span);
    }            


    private static DateTime? TryParseDateTime(string rawDateTime)
    {
        if (DateTime.TryParseExact(rawDateTime, "yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
        {
            return dt;
        }

        return null;
    }
}