Table of Contents

Your own format

Egami's formats are plug-ins like any other: a format you write loads, probes, streams and saves through the same calls as PNG or JPEG, and stays inside the same safety limits. Register it on an EgamiContext and every Load, Probe and Save with that context can use it.

A format is a FormatInfo: a name, extensions, a signature check, a reader and a writer.

/// <summary>A minimal format: "RAW8", width and height as big-endian numbers, then RGBA rows.</summary>
public static class Raw8Format
{
    public static FormatInfo Info { get; } = new()
    {
        Name = "RAW8",
        Extensions = ["raw8"],
        SignatureLength = 4,
        Matches = header => header.StartsWith("RAW8"u8),
        Reader = new Reader(),
        Writer = new Writer(),
    };

    private sealed class Reader : IFormatReader
    {
        public RasterInfo ReadInfo(EgamiInput input, ReaderOptions options)
        {
            (int width, int height) = Header(input);
            return new RasterInfo { Format = Info, Width = width, Height = height, Layout = Rgba8.Layout, FrameCount = 1, EstimatedBytes = 4L * width * height };
        }

        public Raster<TPixel> Read<TPixel>(EgamiInput input, ReaderOptions options)
            where TPixel : unmanaged, IPixelType<TPixel>
        {
            (int width, int height) = Header(input);

            // CreateRaster checks the size against the safety limits before allocating.
            Raster<TPixel> raster = input.CreateRaster<TPixel>(width, height);
            using IMemoryOwner<byte> row = input.RentScratch(width * 4);
            for (int y = 0; y < height; y++)
            {
                input.ReadExactly(row.Memory.Span[..(width * 4)]);
                PixelConvert.Convert<Rgba8, TPixel>(MemoryMarshal.Cast<byte, Rgba8>(row.Memory.Span[..(width * 4)]), raster.Row(y));
            }

            return raster;
        }

        public Raster Read(EgamiInput input, ReaderOptions options) => Read<Rgba8>(input, options);

        private static (int Width, int Height) Header(EgamiInput input)
        {
            input.Skip(4);
            return ((int)input.ReadUInt32BigEndian(), (int)input.ReadUInt32BigEndian());
        }
    }

    private sealed class Writer : IFormatWriter
    {
        public void Write<TPixel>(Raster<TPixel> raster, Stream output, WriterOptions options)
            where TPixel : unmanaged, IPixelType<TPixel>
        {
            Span<byte> header = stackalloc byte[12];
            "RAW8"u8.CopyTo(header);
            BinaryPrimitives.WriteUInt32BigEndian(header[4..], (uint)raster.Width);
            BinaryPrimitives.WriteUInt32BigEndian(header[8..], (uint)raster.Height);
            output.Write(header);
            Rgba8[] row = new Rgba8[raster.Width];
            for (int y = 0; y < raster.Height; y++)
            {
                PixelConvert.Convert<TPixel, Rgba8>(raster.Row(y), row);
                output.Write(MemoryMarshal.AsBytes(row.AsSpan()));
            }
        }
    }
}
// Register it in a context...
EgamiContext context = EgamiContext.Default.WithFormat(Raw8Format.Info);
using (FileStream output = File.Create("duck.raw8"))
{
    image.Save(output, Raw8Format.Info);
}

using Raster back = Raster.Load("duck.raw8", context);

// ...or, once at startup before any other Egami call, make it part of the default context:
// EgamiContext.SetDefault(new EgamiContext().WithFormat(Raw8Format.Info));

Readers read only through EgamiInput, which keeps them inside the safety limits:

Member Use
Peek, Read, ReadExactly, Skip, ReadByte, ReadUInt16/32… Read the data; a short read throws CorruptDataException
CreateRaster<TPixel>(width, height) The raster you decode into, size-checked before any allocation
AddFrame(raster) Another frame for animated formats, counted against MaxFrames and the memory budget
RentScratch(bytes) Working memory counted against the same budget
CheckFrameSize, CheckMemory, CheckFrameCount, CheckMetadataBytes Check a number from the header before acting on it
Warn(raster, id, text) Note something a lenient read recovered from, with a stable id such as Pcx.PaletteMissing; Warn(metadata, id, text) does the same for an open RowReader's metadata
Corrupt(offset, problem) The exception to throw for data that breaks the format's rules

Respect ReaderOptions.Recovery: in Lenient mode keep what decoded and warn; in Strict mode throw. The writer gets the raster and a stream; options come as your own WriterOptions subclass.

Streaming

Two optional interfaces let a format stream. A reader that also implements IRowFormatReader returns a RowReader<TNatural> from Open: the open image hands out rows as RasterDecoder asks for them (top-down, never going back within a frame) and moves through frames with NextFrame. The decoder applies Region, MinimumSize and the pixel conversion after it. A writer that also implements IRowFormatWriter returns a RowEncoder<TPixel> from Start, which takes rows (or frames, for an animation) one at a time; Save, SaveAsync, RasterEncoder and Transcode then all write through it. When streaming, EncodeStart.Whole is null: an option that needs every pixel before the first byte should call EncodeStart.Refuse. An open image checks the header's size with CheckFrameSize before it rents its row buffers, since it creates no raster. An encoder calls EncodeStart.ReportLosses with the SaveKeeps flags for what it writes, so a save that drops alpha, depth, frames or metadata says so in the Warnings, as Egami's own writers do.

/// <summary>The same RAW8 format, streaming: rows go in and out one at a time, so RasterDecoder, RasterEncoder and Transcode stream it.</summary>
public static class StreamingRaw8Format
{
    public static FormatInfo Info { get; } = Raw8Format.Info with { Reader = new Reader(), Writer = new Writer() };

    private sealed class Reader : IFormatReader, IRowFormatReader
    {
        private static readonly IFormatReader Whole = Raw8Format.Info.Reader!;

        public RasterInfo ReadInfo(EgamiInput input, ReaderOptions options) => Whole.ReadInfo(input, options) with { Access = RasterAccess.Streamed };

        public Raster<TPixel> Read<TPixel>(EgamiInput input, ReaderOptions options)
            where TPixel : unmanaged, IPixelType<TPixel> => Whole.Read<TPixel>(input, options);

        public Raster Read(EgamiInput input, ReaderOptions options) => Whole.Read(input, options);

        // The open image reads each row when it is asked for; the decoder crops, reduces and converts after it.
        public RowReader Open(EgamiInput input, ReaderOptions options)
        {
            input.Skip(4);
            int width = (int)input.ReadUInt32BigEndian();
            int height = (int)input.ReadUInt32BigEndian();

            // No raster checks the size here, so check it before the row buffer is allocated.
            input.CheckFrameSize(width, height);
            return new Rows(input, width, height);
        }
    }

    private sealed class Rows(EgamiInput input, int width, int height) : RowReader<Rgba8>
    {
        private readonly Rgba8[] _row = new Rgba8[width];
        private int _next;
        private bool _started;

        public override int Width => width;

        public override int Height => height;

        public override Metadata.RasterMetadata Metadata { get; } = new();

        public override Metadata.FrameMetadata? NextFrame()
        {
            if (_started)
            {
                return null;
            }

            _started = true;
            return new Metadata.FrameMetadata();
        }

        public override void ReadRow<TPixel>(int y, int x, Span<TPixel> destination)
        {
            for (; _next <= y; _next++)
            {
                input.ReadExactly(MemoryMarshal.AsBytes(_row.AsSpan()));
            }

            PixelConvert.Convert<Rgba8, TPixel>(_row.AsSpan(x, destination.Length), destination);
        }
    }

    private sealed class Writer : IFormatWriter, IRowFormatWriter
    {
        public void Write<TPixel>(Raster<TPixel> raster, Stream output, WriterOptions options)
            where TPixel : unmanaged, IPixelType<TPixel> => Raw8Format.Info.Writer!.Write(raster, output, options);

        // Save, SaveAsync, RasterEncoder and Transcode all write through the encoder.
        public RowEncoder<TPixel> Start<TPixel>(Stream output, EncodeStart<TPixel> start)
            where TPixel : unmanaged, IPixelType<TPixel>
        {
            // RAW8 keeps 8-bit RGBA and nothing else: deeper samples or metadata are reported as lost.
            start.ReportLosses("RAW8", SaveKeeps.Alpha);
            return new Encoder<TPixel>(output, start.Width, start.Height);
        }
    }

    private sealed class Encoder<TPixel> : RowEncoder<TPixel>
        where TPixel : unmanaged, IPixelType<TPixel>
    {
        private readonly Stream _output;
        private readonly Rgba8[] _row;

        public Encoder(Stream output, int width, int height)
        {
            _output = output;
            _row = new Rgba8[width];
            Span<byte> header = stackalloc byte[12];
            "RAW8"u8.CopyTo(header);
            BinaryPrimitives.WriteUInt32BigEndian(header[4..], (uint)width);
            BinaryPrimitives.WriteUInt32BigEndian(header[8..], (uint)height);
            output.Write(header);
        }

        public override void WriteRow(ReadOnlySpan<TPixel> row)
        {
            PixelConvert.Convert<TPixel, Rgba8>(row, _row);
            _output.Write(MemoryMarshal.AsBytes(_row.AsSpan()));
        }

        public override void Complete()
        {
        }
    }
}

A format without them still works everywhere: the decoder reads the whole image with Read and serves rows from it, and the encoder gathers the image and calls Write at the end.

Writing your own operation or pixel type is covered in Custom operations and formats.

Converting without decoding

A writer that can turn a file of another format into its own without decoding it to pixels, as DNG keeps a camera's sensor data, also implements IFormatConverter. Transcode offers it the input when there are no operations and the reader options ask for nothing; it decides by the input's format, before reading anything, and returns false to have the file decoded as usual.

/// <summary>Writes RAW8 files; a RAW8 file transcoded to RAW8 with no operations is copied as it is, without decoding it.</summary>
public sealed class CopyingRaw8Writer : IFormatWriter, IFormatConverter
{
    public void Write<TPixel>(Raster<TPixel> raster, Stream output, WriterOptions options)
        where TPixel : unmanaged, IPixelType<TPixel>
    {
        Raw8Format.Info.Writer!.Write(raster, output, options);
    }

    public bool TryConvert(Stream input, FormatInfo inputFormat, Stream output, WriterOptions options, EgamiContext context)
    {
        if (inputFormat.Name != Raw8Format.Info.Name)
        {
            return false;
        }

        input.CopyTo(output);
        return true;
    }
}

Files with several images

A format whose files hold several images, as TIFF holds pages, also implements IMultiImageReader. LoadAll asks it for one ReaderOptions per image, usually a subclass of your own that names the image, and loads each as Load would with those options. A writer that writes several images to one file implements IMultiImageWriter, which SaveAll hands every raster.

/// <summary>A format holding several RAW8 images: "RAW8P", the image count as one byte, then each image as RAW8 stores it.</summary>
public static class Raw8PagesFormat
{
    public static FormatInfo Info { get; } = new()
    {
        Name = "RAW8P",
        Extensions = ["raw8p"],
        SignatureLength = 5,
        Matches = header => header.StartsWith("RAW8P"u8),
        Reader = new Reader(),
    };

    /// <summary>Names the image a Load reads; LoadAll sets it for each.</summary>
    public sealed record PageOptions : ReaderOptions
    {
        public int Page { get; init; }
    }

    private sealed class Reader : IFormatReader, IMultiImageReader
    {
        public IReadOnlyList<ReaderOptions> Images(EgamiInput input, ReaderOptions options)
        {
            input.Skip(5);
            int count = input.ReadByte();
            return [.. Enumerable.Range(0, count).Select(page => new PageOptions { Recovery = options.Recovery, Page = page })];
        }

        public RasterInfo ReadInfo(EgamiInput input, ReaderOptions options)
        {
            (int width, int height, int count) = Seek(input, options);
            return new RasterInfo { Format = Info, Width = width, Height = height, Layout = Rgba8.Layout, FrameCount = 1, ImageCount = count, EstimatedBytes = 4L * width * height };
        }

        public Raster<TPixel> Read<TPixel>(EgamiInput input, ReaderOptions options)
            where TPixel : unmanaged, IPixelType<TPixel>
        {
            (int width, int height, _) = Seek(input, options);
            Raster<TPixel> raster = input.CreateRaster<TPixel>(width, height);
            byte[] row = new byte[width * 4];
            for (int y = 0; y < height; y++)
            {
                input.ReadExactly(row);
                PixelConvert.Convert<Rgba8, TPixel>(MemoryMarshal.Cast<byte, Rgba8>(row.AsSpan()), raster.Row(y));
            }

            return raster;
        }

        public Raster Read(EgamiInput input, ReaderOptions options) => Read<Rgba8>(input, options);

        /// <summary>Skips to the page the options name and reads its size.</summary>
        private static (int Width, int Height, int Count) Seek(EgamiInput input, ReaderOptions options)
        {
            input.Skip(5);
            int count = input.ReadByte();
            int page = (options as PageOptions)?.Page ?? 0;
            for (int i = 0; ; i++)
            {
                input.Skip(4);
                int width = (int)input.ReadUInt32BigEndian();
                int height = (int)input.ReadUInt32BigEndian();
                if (i == page)
                {
                    return (width, height, count);
                }

                input.Skip(4L * width * height);
            }
        }
    }
}

Compressed data

.NET's ZLibStream, DeflateStream, GZipStream and BrotliStream cover the common codecs. For Zstandard, which .NET does not have, core Egami's Zstandard class (in Egami.Compression) compresses and decompresses spans with the managed codec TIFF and KTX 2 use; a warm call allocates nothing.

// Compress into a buffer sized for the worst case, then keep the bytes written.
byte[] packed = new byte[Zstandard.MaxCompressedLength(data.Length)];
int written = Zstandard.Compress(data, packed, level: 9);

// Decompress into a buffer of the size you know (KTX 2 stores each level's): it returns the bytes written.
byte[] unpacked = new byte[data.Length];
int length = Zstandard.Decompress(packed.AsSpan(0, written), unpacked);