Reading Adobe Swatch Exchange (ase) files using C#
Previously I wrote how to read and write files using
the Photoshop Color Swatch file format. In this article
mini-series, I'm now going to take a belated look at Adobe's
Swatch Exchange file format and show how to read and write these
files using C#. This first article covers reading an existing
ase
file.
Caveat Emptor
Unlike some of Adobe's other specifications, they don't seem to
have published an official specification for the ase
format
themselves. For the purposes of this article, I've been using
unofficial details available from Olivier Berten and
HxD to poke around in sample files I have downloaded.
And, as with my previous articles, the code I'm about to present doesn't handle CMYK or Lab colour spaces. It's also received a very limited amount of testing.
Structure of a Adobe Swatch Exchange file
ase
files support the notion of groups, so you can have
multiple groups containing colours. Judging from the files I
have tested, you can also just have a bunch of colours without a
group at all. I'm uncertain if groups can be nested, so I have
assumed they cannot be.
With that said, the structure is relatively straight forward, and helpfully includes data that means I can skip the bits that I have no idea at all what they are. The format comprises of a basic version header, then a number of blocks. Each block includes a type, data length, the block name, and then additional data specific to the block type, and optionally custom data specific to that particular block.
Blocks can either be a colour, the start of a group, or the end of a group.
Colour blocks include the colour space, 1-4 floating point values that describe the colour (3 for RGB and LAB, 4 for CMYK and 1 for grayscale), and a type.
Finally, all blocks can carry custom data. I have no idea what
this data is, but it doesn't seem to be essential nor are you
required to know what it is for in order to pull out the colour
information. Fortunately, as you know how large each block is,
you can skip the remaining bytes from the block and move onto
the next one. As there seems to be little difference between the
purposes of aco
and ase
files (the obvious one being that
the former is just a list of colours while the latter supports
grouping) I assume this data is meta data from the application
that created the ase
file, but it is all supposition.
The following table attempts to describe the layout, although I actually found the highlighted hex grid displayed at selapa.net to potentially be easier to read.
Length | Description | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
4 | Signature | ||||||||||
2 | Major Version | ||||||||||
2 | Minor Version | ||||||||||
4 | Number of blocks | ||||||||||
variable |
Block data
|
||||||||||
Colour blocks only
|
|||||||||||
All blocks
|
As with aco
files, all the data in an ase
file is stored in
big-endian format and therefore needs to be reversed on
Windows systems. Unlike the aco
files where four values are
present for each colour even if not required by the appropriate
colour space, the ase
format uses between one and four values,
making it slightly more compact that aso
.
Colour Spaces
I mentioned above that each colour has a description of what
colour space it belongs to. There appear to be four supported
colour spaces. Note that space names are 4 characters long in an
ase
file, shorter names are therefore padded with spaces.
- RGB
- LAB
- CMYK
- Gray
In my experiments, RGB was easy enough - just multiply the value
read from the file by 255 to get the right value to use with
.NET's Color
structure. I have no idea on the other 3 types
however - I need more samples!
Big-endian conversion
I covered the basics of reading shorts, ints, and strings in
big-endian format in my previous article on aco
files so
I won't cover that here.
However, this time around I do need to read floats from the
files too. While the BitConverter
class has a ToSingle
method that will convert a 4-byte array to a float, of course it
is for little-endian.
I looked at the reference source for this method and saw it does a really neat trick - it converts the four bytes into an integer, then creates a float from that integer via pointers.
So, I used the same approach - read an int in big-endian, then
convert it to a float. The only caveat is that you are using
pointers, meaning unsafe code. By default you can't use the
unsafe
keyword without enabling a special option in project
properties. I use unsafe code quite frequently for working with
image data and generally don't have a problem, if you are
unwilling to enable this option then you can always take the
four bytes, reverse them, and then call BitConverter.ToSingle
with the reversed array.
public static float ReadSingleBigEndian(this Stream stream)
{
unsafe
{
int value;
value = stream.ReadUInt32BigEndian();
return *(float*)&value;
}
}
Another slight difference between aco
and ase
files is that
in ase
files, strings are null terminated, and the name length
includes that terminator. Of course, when reading the strings
back out, we really don't want that terminator to be included.
So I added another helper method to deal with that.
public static string ReadStringBigEndian(this Stream stream)
{
int length;
string value;
// string is null terminated, value saved in file includes the terminator
length = stream.ReadUInt16BigEndian() - 1;
value = stream.ReadStringBigEndian(length);
stream.ReadUInt16BigEndian(); // read and discard the terminator
return value;
}
Storage classes
In my previous examples on reading colour data from files, I've kept it simple and returned arrays of colours, discarding incidental details such as names. This time, I've created a small set of helper classes, to preserve this information and to make it easier to serialize it.
internal abstract class Block
{
public byte[] ExtraData { get; set; }
public string Name { get; set; }
}
internal class ColorEntry : Block
{
public int B { get; set; }
public int G { get; set; }
public int R { get; set; }
public ColorType Type { get; set; }
public Color ToColor()
{
return Color.FromArgb(this.R, this.G, this.B);
}
}
internal class ColorEntryCollection : Collection<ColorEntry>
{ }
internal class ColorGroup : Block, IEnumerable<ColorEntry>
{
public ColorGroup()
{
this.Colors = new ColorEntryCollection();
}
public ColorEntryCollection Colors { get; set; }
public IEnumerator<ColorEntry> GetEnumerator()
{
return this.Colors.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
}
internal class ColorGroupCollection : Collection<ColorGroup>
{ }
internal class SwatchExchangeData
{
public SwatchExchangeData()
{
this.Groups = new ColorGroupCollection();
this.Colors = new ColorEntryCollection();
}
public ColorEntryCollection Colors { get; set; }
public ColorGroupCollection Groups { get; set; }
}
That should be all we need, time to load some files!
Reading the file
To start with, we create a new ColorEntryCollection
that will
be used for global colours (i.e. colour blocks that don't appear
within a group). To make things simple, I'm also creating a
Stack<ColorEntryCollection>
to which I push this global
collection. Later on, when I encounter a start group block, I'll
Push
a new ColorEntryCollection
to this stack, and when I
encounter an end group block, I'll Pop
the value at the top of
the stack. This way, when I encounter a colour block, I can
easily add it to the right collection without needing to
explicitly keep track of the active group or lack thereof.
public void Load(string fileName)
{
Stack<ColorEntryCollection> colors;
ColorGroupCollection groups;
ColorEntryCollection globalColors;
groups = new ColorGroupCollection();
globalColors = new ColorEntryCollection();
colors = new Stack<ColorEntryCollection>();
// add the global collection to the bottom of the stack to handle color blocks outside of a group
colors.Push(globalColors);
using (Stream stream = File.OpenRead(fileName))
{
int blockCount;
this.ReadAndValidateVersion(stream);
blockCount = stream.ReadUInt32BigEndian();
for (int i = 0; i < blockCount; i++)
{
this.ReadBlock(stream, groups, colors);
}
}
this.Groups = groups;
this.Colors = globalColors;
}
After opening a Stream
containing our file data, we need to
check that the stream contains both ase
data, and that the
data is a version we can read. This is done by reading 8 bytes
from the start of the data. The first four are ASCII characters
which should match the string ASEF
, the next two are the major
version and the final two the minor version.
private void ReadAndValidateVersion(Stream stream)
{
string signature;
int majorVersion;
int minorVersion;
// get the signature (4 ascii characters)
signature = stream.ReadAsciiString(4);
if (signature != "ASEF")
{
throw new InvalidDataException("Invalid file format.");
}
// read the version
majorVersion = stream.ReadUInt16BigEndian();
minorVersion = stream.ReadUInt16BigEndian();
if (majorVersion != 1 && minorVersion != 0)
{
throw new InvalidDataException("Invalid version information.");
}
}
Assuming the data is valid, we read the number of blocks in the file, and enter a loop to process each block. For each block, first we read the type of the block, and then the length of the block's data.
How we continue reading from the stream depends on the block type (more on that later), after which we work out how much data is left in the block, read it, and store it as raw bytes on the off-chance the consuming application can do something with it, or for saving back into the file.
This technique assumes that the source stream is seekable. If this is not the case, you'll need to manually keep track of how many bytes you have read from the block to calculate the remaining custom data left to read.
private void ReadBlock(Stream stream, ColorGroupCollection groups, Stack<ColorEntryCollection> colorStack)
{
BlockType blockType;
int blockLength;
int offset;
int dataLength;
Block block;
blockType = (BlockType)stream.ReadUInt16BigEndian();
blockLength = stream.ReadUInt32BigEndian();
// store the current position of the stream, so we can calculate the offset
// from bytes read to the block length in order to skip the bits we can't use
offset = (int)stream.Position;
// process the actual block
switch (blockType)
{
case BlockType.Color:
block = this.ReadColorBlock(stream, colorStack);
break;
case BlockType.GroupStart:
block = this.ReadGroupBlock(stream, groups, colorStack);
break;
case BlockType.GroupEnd:
block = null;
colorStack.Pop();
break;
default:
throw new InvalidDataException($"Unsupported block type '{blockType}'.");
}
// load in any custom data and attach it to the
// current block (if available) as raw byte data
dataLength = blockLength - (int)(stream.Position - offset);
if (dataLength > 0)
{
byte[] extraData;
extraData = new byte[dataLength];
stream.Read(extraData, 0, dataLength);
if (block != null)
{
block.ExtraData = extraData;
}
}
}
Processing groups
If we have found a "start group" block, then we create a new
ColorGroup
object and read the group name. We also push the
group's ColorEntryCollection
to the stack I mentioned earlier.
private Block ReadGroupBlock(Stream stream, ColorGroupCollection groups, Stack<ColorEntryCollection> colorStack)
{
ColorGroup block;
string name;
// read the name of the group
name = stream.ReadStringBigEndian();
// create the group and add it to the results set
block = new ColorGroup
{
Name = name
};
groups.Add(block);
// add the group color collection to the stack, so when subsequent colour blocks
// are read, they will be added to the correct collection
colorStack.Push(block.Colors);
return block;
}
For "end group" blocks, we don't do any custom processing as I
do not think there is any data associated with these. Instead,
we just pop the last value from our colour stack. (Of course,
that means if there is a malformed ase
file containing a group
end without a group start, this procedure is going to crash
sooner or later!
Processing colours
When we hit a colour block, we read the colour's name and the colour mode.
Then, depending on the mode, we read between 1 and 4 float values which describe the colour. As anything other than RGB processing is beyond the scope of this article, I'm throwing an exception for the LAB, CMYK and Gray colour spaces.
For RGB colours, I take each value and multiple it by 255 to get
a value suitable for use with the .NET Color
struct.
After reading the colour data, there's one official value left to read, which is the colour type. This can either be Global (0), Spot (1) or Normal (2).
Finally, I construct a new ColorEntry
object containing the
colour information and add it to whatever ColorEntryCollection
is on the top of the stack.
private Block ReadColorBlock(Stream stream, Stack<ColorEntryCollection> colorStack)
{
ColorEntry block;
string colorMode;
int r;
int g;
int b;
ColorType colorType;
string name;
ColorEntryCollection colors;
// get the name of the color
// this is stored as a null terminated string
// with the length of the byte data stored before
// the string data in a 16bit int
name = stream.ReadStringBigEndian();
// get the mode of the color, which is stored
// as four ASCII characters
colorMode = stream.ReadAsciiString(4);
// read the color data
// how much data we need to read depends on the
// color mode we previously read
switch (colorMode)
{
case "RGB ":
// RGB is comprised of three floating point values ranging from 0-1.0
float value1;
float value2;
float value3;
value1 = stream.ReadSingleBigEndian();
value2 = stream.ReadSingleBigEndian();
value3 = stream.ReadSingleBigEndian();
r = Convert.ToInt32(value1 * 255);
g = Convert.ToInt32(value2 * 255);
b = Convert.ToInt32(value3 * 255);
break;
case "CMYK":
// CMYK is comprised of four floating point values
throw new InvalidDataException($"Unsupported color mode '{colorMode}'.");
case "LAB ":
// LAB is comprised of three floating point values
throw new InvalidDataException($"Unsupported color mode '{colorMode}'.");
case "Gray":
// Grayscale is comprised of a single floating point value
throw new InvalidDataException($"Unsupported color mode '{colorMode}'.");
default:
throw new InvalidDataException($"Unsupported color mode '{colorMode}'.");
}
// the final "official" piece of data is a color type
colorType = (ColorType)stream.ReadUInt16BigEndian();
block = new ColorEntry
{
R = r,
G = g,
B = b,
Name = name,
Type = colorType
};
colors = colorStack.Peek();
colors.Add(block);
return block;
}
And done
The ase
format is pretty simple to process, although the fact
there is still data in these files with an unknown purpose could
be a potential issue. Unfortunately, I don't have a recent
version of PhotoShop to actually generate some of these files to
investigate further (and to test if groups can be nested so I
can adapt this code accordingly).
However, I have tested this code on a number of files downloaded
from the internet and have been able to pull out all the colour
information, so I suspect the Color Palette Editor and
Color Picker Controls will be getting ase
support fairly
soon!
Related articles you may be interested in
Downloads
Filename | Description | Version | Release Date | |
---|---|---|---|---|
AdobeSwatchExchangeLoader.zip
|
Sample project for loading Adobe Swatch Exchange (ase) files using C#. |
1.0.0.0 | 16/10/2015 | Download |
Leave a Comment
While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?