K Cartlidge

C#/DotNet. Go. Node. Python/Flask. Elixir.

Extracting PNG dimensions and DPI using C#

Contents

About PNG files

A PNG image uses a reasonably simple file format, making it easy to extract information.

The file format details are on the PNG file Wikipedia page (we're interested in the header so this link goes straight there).

In brief the file has a few signature bytes, some data we can skip, then a series of chunks each of which can contain either metadata or image data. And each chunk contains it's size so if we're not interested in that chunk we can jump on to the next.

Some of the metadata chunks are compulsory and some are optional. Fortunately for us the dimensions are compulsory (obviously) and whilst the DPI is optional a random sampling of my own PNG files shows it to be filled in for all cases (YMMV).

Overview of the process

Note that in the above logic we do not automatically stop when we reach the IDAT image data chunks as whilst in all my tests the metadata chunks precede the image data chunks the spec doesn't seem to mandate that.

If you'd prefer to stick with the assumption (for performance) that metadata comes first, then when the code that follows drops out on reaching IEND you can do the same upon reaching an IDAT as that marks an image data chunk.

The C# ImageParser

This code reads a PNG file then populates and returns an ImageSize class. That class is defined further down.

Incidentally, it also derives the image’s physical dimensions from the DPI (which defaults to 96x96 if the PHYS chunk is not present). These dimensions are calculated in inches, but are also calculated in EMUs which are used in DOCX (MS Word) files. You can safely ignore them, but they were needed for my own purposes.

public static class ImageParser
{
    /// <summary>
    /// Validates the file is a PNG and returns its dimensions in
    /// pixels, inches, and EMUs (used in DOCX files), plus the DPI.
    ///
    /// If the file is not a PNG, or the file's header does not match
    /// the PNG specification, then the image size will be 0x0.
    ///
    /// The DPIs (which can vary by axis) are optional. If not found
    /// they will default to 96x96. You can use the AssumedDPI property
    /// to check this.
    /// </summary>
    public static ImageSize ParsePNG(string filename)
    {
        var result = new ImageSize(0, 0);
        if (Path.GetExtension(filename).ToUpperInvariant() != ".PNG") return result;

        try
        {
            using (var reader = new BinaryReader(File.OpenRead(filename)))
            {
                // File header - check for the PNG signature.
                if (reader.ReadByte() != 0x89) return result;
                if (string.Join("", reader.ReadChars(5)) != "PNG\r\n") return result;

                // Skip the remaining header to reach the first chunk.
                _ = reader.ReadBytes(2);

                // As per the spec, the first chunk MUST hold the dimensions (an IHDR chunk).
                var headerSize = ReadBigEndianInt32(reader);
                if (string.Join("", reader.ReadChars(4)) != "IHDR") return result;
                var wid = ReadBigEndianInt32(reader);
                var hgt = ReadBigEndianInt32(reader);
                result = new ImageSize(wid, hgt);
                _ = reader.ReadBytes(headerSize - 8);
                var headerCRC = reader.ReadBytes(4);

                // Scan through the chunks, checking their types.
                var maxPos = reader.BaseStream.Length - 1;
                while (reader.BaseStream.Position < maxPos)
                {
                    var chunkSize = ReadBigEndianInt32(reader);
                    var chunkType = string.Join("", reader.ReadChars(4)).ToUpperInvariant();
                    if (chunkType == "PHYS") // Physical dimensions.
                    {
                        var xPxPerUnit = ReadBigEndianInt32(reader);
                        var yPxPerUnit = ReadBigEndianInt32(reader);
                        if (reader.ReadByte() == 1)  // '1' means metres, whereas 0 is undefined.
                        {
                            // Calculate the DPI (dots per inch) from the metre measurements.
                            // The implicit type conversions in the following are important.
                            var xPxPerCM = xPxPerUnit / 100.0;
                            var widDPI = Convert.ToInt32(xPxPerCM * 2.54);
                            var yPxPerCM = yPxPerUnit / 100.0;
                            var hgtDPI = Convert.ToInt32(yPxPerCM * 2.54);
                            result.SetDPI(widDPI, hgtDPI);
                        }
                        break;
                    }
                    else if (chunkType == "IEND") break; // Reached the end. Could also check for "IDAT".
                    else _ = reader.ReadBytes(chunkSize);
                    var crc = reader.ReadBytes(4);
                }
            }
            return result;
        }
        catch
        {
            return new ImageSize(0, 0);
        }
    }

    /// <summary>
    /// Scans and converts the next 4 bytes from the reader assuming a
    /// big-endian 32 bit number (MSB first).
    /// </summary>
    private static int ReadBigEndianInt32(BinaryReader reader)
    {
        var bytes = reader.ReadBytes(4);
        if (BitConverter.IsLittleEndian) bytes = bytes.Reverse().ToArray();
        return BitConverter.ToInt32(bytes);
    }
}

Big-endian and little-endian numbers

With regard to numeric values in the header it should be noted they are big-endian, which means the most significant byte is stored first. So for example the number 1033 is the 4 byte sequence [0,0,4,9] as read from the file. This is (4*256)+9.

Unfortunately Intel and most ARM chips are little-endian, where the bytes are expected in the opposite order. This means we cannot just read an integer by scanning the bytes; they may need reversing to [9,4,0,0] before the conversion. That's what the ReadBigEndianInt32 method is checking for and doing.

The C# ImageSize

Why a special class for the result? Why not just a tuple for the width and height? Because we may be able to obtain the DPI. And that in turn will provide us (using maths) with physical dimensions. You may not need those details so feel free to drop the class, but for my purposes it helped as DOCX (MS Word) documents expect physical dimensions (in EMUs) for embedded images which is something I do in a personal project.

/// <summary>
/// Stores details of an image's dimensions in pixels.
/// If the DPI is set then it is used to calculate the
/// dimensions in both inches and EMUs.
/// The latter are Open XML units (DOCX/Word), which
/// are specified in EMUs ("English Metric Units").
/// </summary>
public class ImageSize
{
    // The only properties guaranteed present by the PNG spec.
    public int Width { get; private set; } = 0;
    public int Height { get; private set; } = 0;

    // Required properties if we are to calculate physical sizes.
    public int WidthDPI { get; private set; } = 96;
    public int HeightDPI { get; private set; } = 96;

    // Calculated physical size in inches.
    public double WidthInches { get; private set; } = 0;
    public double HeightInches { get; private set; } = 0;

    // Calculated physical size in EMUs (DOCX/Word).
    public int WidthEMU { get; private set; } = 0;
    public int HeightEMU { get; private set; } = 0;

    /// <summary>Indicates we don't have the real DPI.</summary>
    public bool AssumedDPI { get; private set; } = true;

    public ImageSize(int width, int height)
    {
        Width = Math.Max(0, width);
        Height = Math.Max(0, height);
    }

    /// <summary>Set the DPI (which also calculates the physical size).</summary>
    public void SetDPI(int widthDPI, int heightDPI)
    {
        // Gatekeeping the inputs.
        if (widthDPI == 0 || heightDPI == 0)
        {
            AssumedDPI = true;
            WidthDPI = HeightDPI = 96;
        }
        else
        {
            AssumedDPI = false;
            (WidthDPI, HeightDPI) = (widthDPI, heightDPI);
        }

        WidthInches = Math.Round((1.0 * Width) / WidthDPI, 3);
        HeightInches = Math.Round((1.0 * Height) / HeightDPI, 3);

        WidthEMU = Convert.ToInt32(WidthInches * 914400);
        HeightEMU = Convert.ToInt32(HeightInches * 914400);
    }
}

There are other chunk types in the header that may be of use (eg the colour palette) so it's worth checking the Wikipedia link at the top of this page.

Using the code

Here's some sample usage:

namespace TestPNG;

class Program
{
    static void Main(string[] args)
    {
        var size = ImageParser.ParsePNG("wikipedia-pd-image.png");
    }
}

And here's a sample result:

PROPERTY         VALUE   NOTE
AssumedDPI       False   Found a PHYS chunk
Height             112   Pixels
HeightDPI           96   From the PHYS metres
HeightEMU      1067105   Calculated
HeightInches     1.167   Calculated
Width              172   Pixels
WidthDPI            96   From the PHYS metres
WidthEMU       1638605   Calculated
WidthInches      1.792   Calculated

The code is licensed as public domain. The text is copyright, but feel free to link to this page if you use the code and want to provide an overview.