Editor/Core/UnityImageSize.cs

Editor utility that reads image metadata without decoding pixels. It detects PNG, BMP, JPEG, Photoshop (PSD), TIFF, TGA and EXR headers to return width/height or EXR compression, and includes helpers for power-of-two checks and nearest power-of-two calculation used by texture import logic.

File AccessNative Interop
using System;
using System.IO;

namespace ImportUnityPackage;

/// <summary>Reads image dimensions from file headers without decoding pixels. Unknown formats return null.</summary>
internal static class UnityImageSize
{
	internal static (int Width, int Height)? Read( string path )
	{
		try
		{
			using var stream = File.OpenRead( path );
			var header = new byte[30];
			if ( stream.Read( header ) < 26 ) return null;
			int Big32( int at ) => header[at] << 24 | header[at + 1] << 16 | header[at + 2] << 8 | header[at + 3];
			int Little16( int at ) => header[at] | header[at + 1] << 8;
			if ( header[0] == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G' ) return Valid( Big32( 16 ), Big32( 20 ) );
			if ( header[0] == 'B' && header[1] == 'M' ) return Valid( Little16( 18 ) | Little16( 20 ) << 16, Math.Abs( Little16( 22 ) | Little16( 24 ) << 16 ) );
			if ( header[0] == 0xFF && header[1] == 0xD8 ) return Jpeg( stream );
			// Photoshop: signature, version, 6 reserved bytes, channels, then height and width (big-endian).
			if ( header[0] == '8' && header[1] == 'B' && header[2] == 'P' && header[3] == 'S' ) return Valid( Big32( 18 ), Big32( 14 ) );
			if ( header[0] == 'I' && header[1] == 'I' && header[2] == 42 || header[0] == 'M' && header[1] == 'M' && header[3] == 42 ) return Tiff( stream, header[0] == 'I' );
			// TGA has no signature; accept only uncompressed or RLE true-color, greyscale and palette images.
			if ( Path.GetExtension( path ).Equals( ".tga", StringComparison.OrdinalIgnoreCase ) && header[2] is 1 or 2 or 3 or 9 or 10 or 11 )
				return Valid( Little16( 12 ), Little16( 14 ) );
			return null;
		}
		catch ( IOException ) { return null; }
	}

	/// <summary>A TIFF's first image size: the ImageWidth (256) and ImageLength (257) entries of its first directory.</summary>
	static (int, int)? Tiff( Stream stream, bool little )
	{
		var buffer = new byte[12];
		int U16( int at ) => little ? buffer[at] | buffer[at + 1] << 8 : buffer[at] << 8 | buffer[at + 1];
		long U32( int at ) => little ? (uint)(buffer[at] | buffer[at + 1] << 8 | buffer[at + 2] << 16 | buffer[at + 3] << 24) : (uint)(buffer[at] << 24 | buffer[at + 1] << 16 | buffer[at + 2] << 8 | buffer[at + 3]);
		stream.Position = 4;
		if ( stream.Read( buffer, 0, 4 ) != 4 ) return null;
		var directory = U32( 0 );
		if ( directory <= 0 || directory >= stream.Length ) return null;
		stream.Position = directory;
		if ( stream.Read( buffer, 0, 2 ) != 2 ) return null;
		var count = U16( 0 );
		int width = 0, height = 0;
		for ( var i = 0; i < count && i < 512; i++ )
		{
			if ( stream.Read( buffer, 0, 12 ) != 12 ) return null;
			var tag = U16( 0 );
			if ( tag is not (256 or 257) ) continue;
			// SHORT (3) values sit in the first two bytes of the value field, LONG (4) in all four.
			var value = U16( 2 ) == 3 ? U16( 8 ) : (int)U32( 8 );
			if ( tag == 256 ) width = value; else height = value;
		}
		return Valid( width, height );
	}

	static (int, int)? Valid( int width, int height ) => width > 0 && height > 0 && width <= 65536 && height <= 65536 ? (width, height) : null;

	static (int, int)? Jpeg( Stream stream )
	{
		// Walk marker segments to the first start-of-frame (SOF0-SOF15, except DHT/JPG/DAC).
		stream.Position = 2;
		var buffer = new byte[7];
		while ( stream.Read( buffer, 0, 4 ) == 4 )
		{
			if ( buffer[0] != 0xFF ) return null;
			var marker = buffer[1];
			var length = buffer[2] << 8 | buffer[3];
			if ( length < 2 ) return null;
			if ( marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC )
			{
				if ( stream.Read( buffer, 0, 5 ) != 5 ) return null;
				return Valid( buffer[3] << 8 | buffer[4], buffer[1] << 8 | buffer[2] );
			}
			stream.Position += length - 2;
		}
		return null;
	}

	/// <summary>An OpenEXR file's compression attribute (0 none … 8 DWAA, 9 DWAB), or null when it cannot be read.</summary>
	internal static int? ExrCompression( string path )
	{
		try
		{
			using var stream = File.OpenRead( path );
			var header = new byte[Math.Min( 65536, stream.Length )];
			stream.ReadExactly( header );
			if ( header.Length < 8 || header[0] != 0x76 || header[1] != 0x2F || header[2] != 0x31 || header[3] != 0x01 ) return null;
			// Attributes: name\0 type\0 size(int32) value, until an empty name.
			var at = 8;
			while ( at < header.Length && header[at] != 0 )
			{
				var nameEnd = Array.IndexOf( header, (byte)0, at );
				var typeEnd = nameEnd < 0 ? -1 : Array.IndexOf( header, (byte)0, nameEnd + 1 );
				if ( typeEnd < 0 || typeEnd + 5 > header.Length ) return null;
				var name = System.Text.Encoding.ASCII.GetString( header, at, nameEnd - at );
				var size = BitConverter.ToInt32( header, typeEnd + 1 );
				if ( size < 0 ) return null; // Corrupt: a negative size would move backwards and never end.
				if ( name == "compression" && size == 1 && typeEnd + 5 < header.Length ) return header[typeEnd + 5];
				at = typeEnd + 5 + size;
			}
			return null;
		}
		catch ( IOException ) { return null; }
	}

	internal static bool IsPowerOfTwo( int value ) => value > 0 && (value & (value - 1)) == 0;

	/// <summary>Unity's ClosestPowerOfTwo, used by the texture importer's nPOTScale "ToNearest" (ties round down).</summary>
	internal static int NearestPowerOfTwo( int value )
	{
		if ( value <= 1 ) return 1;
		var next = 1;
		while ( next < value ) next <<= 1;
		var previous = next >> 1;
		return next - value < value - previous ? next : previous;
	}
}