Category Archives: Julia

Learn With Me: Julia – Structs and Binary I/O (#3)

By: Fabian Becker

Re-posted from: https://geekmonkey.org/learn-with-me-julia-structs-and-binary-i-o-3/

Learn With Me: Julia - Structs and Binary I/O (#3)

Diagrams.net (formerly draw.io) is a fantastic website and tool that allows you do create rich diagrams. The service is entirely free and diagrams can be saved to you Google Drive, Dropbox or downloaded to your computer. Additionally diagrams.net allows you to export your diagrams to various formats such as SVG, JPG and PNG.

Recently it was pointed out to me that you can actually load an exported PNG diagram back into the tool and edit it again. This got me thinking – how are they doing this? Surely they aren't using image recognition techniques to identify objects in the image?

You may wonder: What does all of this have to do with the title of this post? Let's talk about the PNG format a little.

PNG File Format

PNG stands for Portable Network Graphics. The format has existed in some form since the mid-nineties. Like all binary file formats it follows a specification. From the specification we can learn a lot about how the image and its metadata is represented on disk.

File Header

A PNG file starts with an 8-byte signature. This signature tells a decoder that all bytes that follow are to be interpreted based on the PNG spec. In hexadecimal representation the header is: 89 50 4e 47 0d 0a 1a 0a

Chunks

The remainder of the PNG format follows a very simple structure. Data is represented in chunks. Each chunk starts with 4 bytes describing the length of chunk data. Then follow 4 bytes for the chunk type. This again is followed by length bytes of chunk data and finally 4 more bytes for a CRC (cyclic-redundancy check). The CRC can be computed over the chunk type and chunk data.

Length Chunk type Chunk data CRC
4 bytes 4 bytes Length bytes 4 bytes

The file specification mentions that while the length is represented using 4 bytes or 32bits the maximum length of chunk data is actually 2^31.

The chunk types are more interesting as there are plenty of them. I won't go into much detail here and instead only cover the relevant bits for this post. I encourage you to go read the specification for yourself to understand the nifty encoding techniques used here.

Since the chunk type is represented by 4 bytes, they can (mostly) be represented using 4 ASCII characters. Chunk types are split into critical and ancillary chunks – a decoder must understand all critical chunks but can safely ignore the ancillary chunks.

The critical chunks are as follows:

  • IHDR must be the first chunk in the file. It contains in specific order the image width, height, bit depth, color type, compression method, filter method and interlace method.
  • PLTE contains information about the color palette used
  • IDAT contains the actual image. There can be multiple IDAT chunks which is what allows PNG to be a streamable format in which the first smaller IDAT chunk allows a pre-render of the full image before all data is received.
  • IEND marks the end of the file

A selection of ancillary chunks:

  • tIME stores the time the image was last changed
  • tEXt stores key-value metadata. The text is encoded in ISO 8859-1. The key must be between 1 and 79 characters long and is terminated by a null character. The remainder of chunk data is the value.

Exporting a diagram from diagrams.net

Before we get started with writing some Julia code, let's first export a PNG file from diagrams.net.

This is fairly straight forward, just head over to diagrams.net, click together a diagram and hit File > Export and choose PNG. Make sure to keep the "Include a copy of my diagram" checkbox checked.

Learn With Me: Julia - Structs and Binary I/O (#3)
Sample diagram

File IO in Julia

With everything prepared we can start looking into I/O. We're not going to do anything advanced here so we'll just look at the basics.

Interacting with files, regardless of the language, always follows the same pattern:

  • Open a file for reading/writing
  • Read/write
  • Close the file descriptor

Julia is no exception to this. We can use Base.open to open a file. This will give us an IOStream instance which in turn wraps the OS file descriptor. We can either do it in a block, in which case the file will be closed automatically at the end of the block, or we call open/close separately.

open("myfile.txt", "r") do io
   # ...
end;

Furthermore there are multiple ways to read data from a file.

We'll need read and readbytes!. They both take an IOStream (the result of the open call) as the first argument. read takes a primitive type as a second argument telling it to read a single value of that type from the IO and return it. I.e. read(io, UInt32) will read the 4 bytes it takes to represent a UInt32.

readbytes! requires a vector like object to be passed as its second argument. It will read as many bytes as the vector can hold as long as there's data to read.

Reading in the PNG file

Let's put what we've just learned together. Here's the plan:

  • Open the PNG file
  • Check for the file header (remember those 8 bytes mentioned above?)
  • Read in PNG chunks by first consuming the length, the type, the data based on the length field and finally the CRC.

We can represent PNG chunks using a struct with named fields for each of the elements. The easiest way to represent a sequence of bytes is using a Vector{UInt8}. Here's the struct I came up with:

struct PNGChunk
    length::UInt32
    type::Vector{UInt8}
    data::Vector{UInt8}
    crc::Vector{UInt8}
end

It's also useful to declare a constant for holding the PNG header:

const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]

Let's now open the PNG file and read in the first 8 bytes for the header:

io = open("Diagram.png", "r")
header = Vector{UInt8}(undef, 8)
readbytes!(io, header)

readbytes! takes an IOStream handle and a variable that it will try to fill. You can pass it an additional integer to indicate the number of bytes to read but it defaults to the length of the second argument which we've declared as a vector of UInt8s with 8 elements.

By simply comparing header with PNG_HEADER we can determine whether we're dealing with a valid PNG file:

if header ≠ PNG_HEADER
    throw(ArgumentError("File is not a PNG"))
end

Assuming our file is valid we can now attempt to read in all the chunks in the file. It's easiest to do this iteratively with a loop and consume the file until we hit EOF. Luckily Julia provides an eof function that takes an IOStream and returns whether or not we've reached the end of the file.

while !eof(io)
    length = hton(read(io, UInt32))

    type = Vector{UInt8}(undef, 4)
    readbytes!(io, type)

    data = Vector{UInt8}(undef, length)
    readbytes!(io, data)

    crc = Vector{UInt8}(undef, 4)
    readbytes!(io, crc)

    push!(chunks, PNGChunk(length, type, data, crc))
end

I'm calling hton to get the length represented properly. This is because my system (Intel-based MacBook Pro) is a little endian system (meaning the least significant byte comes first) but PNG represents all data in big endian requiring us to reorder bytes.

The loop will continue to consume bytes for the chunk type, data and the CRC and construct a PNGChunk that will then be pushed into a vector.

Note: The above code will work for a valid PNG file. There's no error checking at all so if one of the fields is corrupted or the file ends prematurely this will throw an error and fail.

Displaying chunks

Now that we're done reading the file we should take a look at its contents. For this we can add a bunch of helper functions.

We essentially want to run something like:

for chunk in chunks
    print(chunk)
end

but executing this will result in a lot of gibberish being displayed. To tell Julia how to display a PNGChunk we need to implement Base.show for our type. Base.show takes an IO object and an instance of a type. You can compare this with __repr__ in Python. An implementation that will display the length and the type of a chunk might look as follows:

function Base.show(io::IO, c::PNGChunk)
    println(io, length(c), "\t", type(c))
end

Where in other languages you declare methods on classes, in Julia you simply declare a function that operates on a type. To make the implementation of Base.show work we need to define length and type:

length(c::PNGChunk) = c.length
type(c::PNGChunk) = String(Char.(c.type))

While we could simply access chunk.length directly it's common practice to consider struct fields "private" and write functions to access them. This way you get a layer of abstraction and can easily change the layout of structs without breaking code all over the place.

To deconstruct what's going on in the second line let's start by looking at c.type. We declared the type to be a Vector{UInt8} and we consumed 4 bytes while reading the PNG file. The first thing we want to do is convert each item in the vector to its ASCII character representation. Julia provides the Char data type to represent 32-bit characters. Simply calling Char(c.type) would result in Julia attempting to consume all 4 bytes (32 bit) and won't give us the desired result.

Instead we can iterate over the items in the vector and convert each item to a Char. This could be written using a list comprehension like [Char(ch) for ch in c.type] which is rather lengthy but standard if you're coming from Python. Julia conveniently offers the dot-operator (also called broadcast) which can be applied to any function. By writing Char.(c.type) we're essentially expressing "apply each element in c.type to the Char function".

Finally we wanted to obtain the string representation of those characters and by passing a Vector{Char} to the String function we can cast it into a string.

More tenured Julia developers would probably write all of the above simply as collect(Char, c.type) |> join, but we're going to ignore this for now.

Ok, back to displaying the chunk. With Base.show and our two functions out of the way we can loop over the chunks and see what's inside our file:

13      IHDR
970     tEXt
3379    IDAT
0       IEND

So that's cool – we've got three chunks with data. IHDR contains height, width, color depth and some other metadata about the file and IDAT contains the actual image. This leaves tEXt which could contain anything.

Extracting information from IHDR

Let's see if we can make sense of the data in the IHDR chunk. First we're going to modify our Base.show implementation to also display the data field when we recognise the chunk type.

function Base.show(io::IO, c::PNGChunk)
    println(io, length(c), "\t", type(c) ,"\t", datastr(c))
end

The specification tells us that there are 13 bytes reserved for the IHDR data field and how many bytes are reserved for different properties.

The IHDR chunk must appear FIRST. It contains:

   Width:              4 bytes
   Height:             4 bytes
   Bit depth:          1 byte
   Color type:         1 byte
   Compression method: 1 byte
   Filter method:      1 byte
   Interlace method:   1 byte

The multi-byte fields will require endian conversion. Since we have already read in all data we need to reinterpret the data from our Vector{UInt8}. That's exactly the name of a Julia function that helps with reinterpreting data into another type:

hton(reinterpret(UInt32, c.data[1:4])[1])

This will take the first four bytes of chunk data and reinterpret them into a UInt32. The wrapping hton will make sure to convert from host byte order to big endian. We can repeat this for the height field and then read all the individual bytes.

function datastr(c::PNGChunk)
    if type(c) == "IHDR"
        height = hton(reinterpret(UInt32, c.data[1:4])[1])
        width = hton(reinterpret(UInt32, c.data[5:8])[1])
        depth, ct, cm, fm, im = c.data[9:13]
        return "h=$height, w=$width, d=$depth, color type=$ct, compression method=$cm, filter method=$fm, interlace method=$im"
    end
    ""
end

For my diagram I get the following output:

h=121, w=201, d=8, color type=6, compression method=0, filter method=0, interlace method=0

Obtaining the original diagram from tEXt

Finally, let's peek inside the tEXt chunk. We can first extend our datastr(c::PNGChunk) function to also have a branch to catch the tEXt type and simply print the contents of the data field:

 mxfile %3Cmxfile%20host%3D%22app.diagrams.net%22%20modified%3D%222021-05-24T09%3A22%3A42.489Z%22%20agent%3D%225.0%20
(Macintosh%3B%20Intel%20Mac%20OS%20X%2010_15_7)....

That's a bunch of gibberish. Consulting the specification tells us that the data field for tEXt consists of a key and value pair separated by a null-byte. That should be easy to parse:

key, value = split(String(Char.(c.data)), '\0')

But that's only half the equation. It looks like the value part may be URL encoded and so we need to find a way to decode it. I couldn't find this functionality in the standard library and so I ended up installing URLParser.jl which implements unescape.

(@v1.6) pkg> add URLParser

Putting everything together we can complete our datastr function by adding tEXt handling:

    elseif type(c) == "tEXt"
        key, value = split(String(Char.(c.data)), '\0')
        value = unescape(value)
        return "$key, $value"
    end

And so the final output is:

13      IHDR    h=121, w=201, d=8, color type=6, compression method=0, filter method=0, interlace method=0
970     tEXt    mxfile, <mxfile host="app.diagrams.net" modified="2021-05-24T09:22:42.489Z" agent="5.0 (Maci
ntosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36" et
ag="MSmUq0enpJxQ3pDGyP_L" version="14.3.0"><diagram id="py8BCTe_me7SkJGnhe6H" name="Page-1">zZRNb4MwDEB/DcdJ
EDbaHruOdYdNm9TDdo2IC5kCRsF89dcvjFCKWKvtUGmXijw7dfxwcPxN2mw1z5MXFKAc5orG8R8cxpZLZn470FqwcHsQayl65I1gJw9g4ZBW
SgHFJJEQFcl8CiPMMohowrjWWE/T9qimVXMewwzsIq7m9F0KSiz1gtUYeAIZJ7b0ki36QMqHZNtJkXCB9QnyQ8ffaETqn9JmA6pzN3jp9z2e
iR4PpiGj32wItquqWN+tgjC+3fPXj0Ks4cbv/6XiqrQN28NSOxgAYYTYJWpKMMaMq3Ck9xrLTEBXxjWrMecZMTfQM/ATiFr7dnlJaFBCqbLR
vmZX6GxvFhVY6gguNDTMCNcx0IU8dnwDZnIBUyDdmn0aFCdZTc/B7QzFx7xRs3mwpv9g3ZtZX6f8ILN4Jn9U23mqE0mwy/m3gdrct580VqAJ
mssi543bDcy102qvqzdc1/pk+IeJTk7mPnCv5IrNXL0ppOLfmfK965kyy/E78R07+dj64Rc=</diagram></mxfile>
3379    IDAT
0       IEND

The secret to how diagrams.net embeds the diagram is solved. It's urlencoded XML embedded into a tEXt chunk inside the PNG file (now that's a fun sentence to say!).

The full code can be found at https://github.com/halfdan/geekmonkey/tree/main/julia/lwm-03

Summary

In this article we've covered a lot of different concepts in Julia. If you struggled to keep up – don't worry I'll go over all the concepts mentioned here in more detail in future posts. My approach to learning is often guided by the projects I want to do and so I often jump in at the deep end. As a result this article introduced concepts rather rapidly without spending too much time on the mechanics.

It's always fascinating when you think about how many things we take for granted in tech without thinking about the underlying mechanics. I was definitely surprised how easy it was to extract some metadata from a binary format like PNG. I've used PNG files for decades without ever thinking about their inner structure. Clearly we've only scratched the surface and haven't looked at the IDAT chunk containing all the image information, but we'll get there in time.


If you like articles like this one, please consider subscribing to my free newsletter where at least once a week I send out my latest work covering Julia, Python, Machine Learning and other tech.

You can also follow me on Twitter.

Learn With Me: Julia – Structs and Binary I/O (#3)

By: Fabian Becker

Re-posted from: https://geekmonkey.org/learn-with-me-julia-structs-and-binary-i-o-3/

Learn With Me: Julia - Structs and Binary I/O (#3)

Diagrams.net (formerly draw.io) is a fantastic website and tool that allows you to create rich diagrams. The service is entirely free and diagrams can be saved to your Google Drive, Dropbox, or downloaded to your computer. Additionally, diagrams.net allows you to export your diagrams to various formats such as SVG, JPG, and PNG.

Recently it was pointed out to me that you can actually load an exported PNG diagram back into the tool and edit it again. This got me thinking – how are they doing this? Surely they aren't using image recognition techniques to identify objects in the image?

You may wonder: What does all of this have to do with the title of this post? Let's talk about the PNG format a little.

PNG File Format

PNG stands for Portable Network Graphics. The format has existed in some form since the mid-nineties. Like all binary file formats, it follows a specification. From the specification, we can learn a lot about how the image and its metadata is represented on disk.

File Header

A PNG file starts with an 8-byte signature. This signature tells a decoder that all bytes that follow are to be interpreted based on the PNG spec. In hexadecimal representation the header is: 89 50 4e 47 0d 0a 1a 0a

Chunks

The remainder of the PNG format follows a very simple structure. Data is represented in chunks. Each chunk starts with 4 bytes describing the length of chunk data. Then follow 4 bytes for the chunk type. This again is followed by length bytes of chunk data and finally 4 more bytes for a CRC (cyclic-redundancy check). The CRC can be computed over the chunk type and chunk data.

Length Chunk type Chunk data CRC
4 bytes 4 bytes Length bytes 4 bytes

The file specification mentions that while the length is represented using 4 bytes or 32bits the maximum length of chunk data is actually 2^31.

The chunk types are more interesting as there are plenty of them. I won't go into much detail here and instead only cover the relevant bits for this post. I encourage you to go read the specification for yourself to understand the nifty encoding techniques used here.

Since the chunk type is represented by 4 bytes, they can (mostly) be represented using 4 ASCII characters. Chunk types are split into critical and ancillary chunks – a decoder must understand all critical chunks but can safely ignore the ancillary chunks.

The critical chunks are as follows:

  • IHDR must be the first chunk in the file. It contains in specific order the image width, height, bit depth, color type, compression method, filter method and interlace method.
  • PLTE contains information about the color palette used
  • IDAT contains the actual image. There can be multiple IDAT chunks which is what allows PNG to be a streamable format in which the first smaller IDAT chunk allows a pre-render of the full image before all data is received.
  • IEND marks the end of the file

A selection of ancillary chunks:

  • tIME stores the time the image was last changed
  • tEXt stores key-value metadata. The text is encoded in ISO 8859-1. The key must be between 1 and 79 characters long and is terminated by a null character. The remainder of chunk data is the value.

Exporting a diagram from diagrams.net

Before we get started with writing some Julia code, let's first export a PNG file from diagrams.net.

This is fairly straightforward, just head over to diagrams.net, click together a diagram and hit File > Export and choose PNG. Make sure to keep the "Include a copy of my diagram" checkbox checked.

Learn With Me: Julia - Structs and Binary I/O (#3)
Sample diagram

File IO in Julia

With everything prepared we can start looking into I/O. We're not going to do anything advanced here so we'll just look at the basics.

Interacting with files, regardless of the language, always follows the same pattern:

  • Open a file for reading/writing
  • Read/write
  • Close the file descriptor

Julia is no exception to this. We can use Base.open to open a file. This will give us an IOStream instance which in turn wraps the OS file descriptor. We can either do it in a block, in which case the file will be closed automatically at the end of the block, or we call open/close separately.

open("myfile.txt", "r") do io
   # ...
end;

Furthermore, there are multiple ways to read data from a file.

We'll need read and readbytes!. They both take an IOStream (the result of the open call) as the first argument. read takes a primitive type as a second argument telling it to read a single value of that type from the IO and return it. I.e. read(io, UInt32) will read the 4 bytes it takes to represent a UInt32.

readbytes! requires a vector-like object to be passed as its second argument. It will read as many bytes as the vector can hold as long as there's data to read.

Reading in the PNG file

Let's put what we've just learned together. Here's the plan:

  • Open the PNG file
  • Check for the file header (remember those 8 bytes mentioned above?)
  • Read in PNG chunks by first consuming the length, the type, the data based on the length field and finally the CRC.

We can represent PNG chunks using a struct with named fields for each of the elements. The easiest way to represent a sequence of bytes is using a Vector{UInt8}. Here's the struct I came up with:

struct PNGChunk
    length::UInt32
    type::Vector{UInt8}
    data::Vector{UInt8}
    crc::Vector{UInt8}
end

It's also useful to declare a constant for holding the PNG header:

const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]

Let's now open the PNG file and read in the first 8 bytes for the header:

io = open("Diagram.png", "r")
header = Vector{UInt8}(undef, 8)
readbytes!(io, header)

readbytes! takes an IOStream handle and a variable that it will try to fill. You can pass it an additional integer to indicate the number of bytes to read but it defaults to the length of the second argument which we've declared as a vector of UInt8s with 8 elements.

By simply comparing header with PNG_HEADER we can determine whether we're dealing with a valid PNG file:

if header ≠ PNG_HEADER
    throw(ArgumentError("File is not a PNG"))
end

Assuming our file is valid we can now attempt to read in all the chunks in the file. It's easiest to do this iteratively with a loop and consume the file until we hit EOF. Luckily Julia provides an eof function that takes an IOStream and returns whether or not we've reached the end of the file.

while !eof(io)
    length = hton(read(io, UInt32))

    type = Vector{UInt8}(undef, 4)
    readbytes!(io, type)

    data = Vector{UInt8}(undef, length)
    readbytes!(io, data)

    crc = Vector{UInt8}(undef, 4)
    readbytes!(io, crc)

    push!(chunks, PNGChunk(length, type, data, crc))
end

I'm calling hton to get the length represented properly. This is because my system (Intel-based MacBook Pro) is a little-endian system (meaning the least significant byte comes first) but PNG represents all data in big-endian requiring us to reorder bytes.

The loop will continue to consume bytes for the chunk type, data, and the CRC and construct a PNGChunk that will then be pushed into a vector.

Note: The above code will work for a valid PNG file. There's no error checking at all so if one of the fields is corrupted or the file ends prematurely this will throw an error and fail.

Displaying chunks

Now that we're done reading the file we should take a look at its contents. For this, we can add a bunch of helper functions.

We essentially want to run something like:

for chunk in chunks
    print(chunk)
end

but executing this will result in a lot of gibberish being displayed. To tell Julia how to display a PNGChunk we need to implement Base.show for our type. Base.show takes an IO object and an instance of a type. You can compare this with __repr__ in Python. An implementation that will display the length and the type of a chunk might look as follows:

function Base.show(io::IO, c::PNGChunk)
    println(io, length(c), "\t", type(c))
end

Where in other languages you declare methods on classes, in Julia you simply declare a function that operates on a type. To make the implementation of Base.show work we need to define length and type:

length(c::PNGChunk) = c.length
type(c::PNGChunk) = String(Char.(c.type))

While we could simply access chunk.length directly it's common practice to consider struct fields "private" and write functions to access them. This way you get a layer of abstraction and can easily change the layout of structs without breaking code all over the place.

To deconstruct what's going on in the second line let's start by looking at c.type. We declared the type to be a Vector{UInt8} and we consumed 4 bytes while reading the PNG file. The first thing we want to do is convert each item in the vector to its ASCII character representation. Julia provides the Char data type to represent 32-bit characters. Simply calling Char(c.type) would result in Julia attempting to consume all 4 bytes (32 bit) and won't give us the desired result.

Instead, we can iterate over the items in the vector and convert each item to a Char. This could be written using a list comprehension like [Char(ch) for ch in c.type] which is rather lengthy but standard if you're coming from Python. Julia conveniently offers the dot-operator (also called broadcast) which can be applied to any function. By writing Char.(c.type) we're essentially expressing "apply each element in c.type to the Char function".

Finally, we wanted to obtain the string representation of those characters and by passing a Vector{Char} to the String function we can cast it into a string.

More tenured Julia developers would probably write all of the above simply as collect(Char, c.type) |> join, but we're going to ignore this for now.

Ok, back to displaying the chunk. With Base.show and our two functions out of the way we can loop over the chunks and see what's inside our file:

13      IHDR
970     tEXt
3379    IDAT
0       IEND

So that's cool – we've got three chunks with data. IHDR contains height, width, color depth and some other metadata about the file and IDAT contains the actual image. This leaves tEXt which could contain anything.

Extracting information from IHDR

Let's see if we can make sense of the data in the IHDR chunk. First we're going to modify our Base.show implementation to also display the data field when we recognize the chunk type.

function Base.show(io::IO, c::PNGChunk)
    println(io, length(c), "\t", type(c) ,"\t", datastr(c))
end

The specification tells us that there are 13 bytes reserved for the IHDR data field and how many bytes are reserved for different properties.

The IHDR chunk must appear FIRST. It contains:

   Width:              4 bytes
   Height:             4 bytes
   Bit depth:          1 byte
   Color type:         1 byte
   Compression method: 1 byte
   Filter method:      1 byte
   Interlace method:   1 byte

The multi-byte fields will require endian conversion. Since we have already read in all data we need to reinterpret the data from our Vector{UInt8}. That's exactly the name of a Julia function that helps with reinterpreting data into another type:

hton(reinterpret(UInt32, c.data[1:4])[1])

This will take the first four bytes of chunk data and reinterpret them into a UInt32. The wrapping hton will make sure to convert from host byte order to big endian. We can repeat this for the height field and then read all the individual bytes.

function datastr(c::PNGChunk)
    if type(c) == "IHDR"
        height = hton(reinterpret(UInt32, c.data[1:4])[1])
        width = hton(reinterpret(UInt32, c.data[5:8])[1])
        depth, ct, cm, fm, im = c.data[9:13]
        return "h=$height, w=$width, d=$depth, color type=$ct, compression method=$cm, filter method=$fm, interlace method=$im"
    end
    ""
end

For my diagram I get the following output:

h=121, w=201, d=8, color type=6, compression method=0, filter method=0, interlace method=0

Obtaining the original diagram from tEXt

Finally, let's peek inside the tEXt chunk. We can first extend our datastr(c::PNGChunk) function to also have a branch to catch the tEXt type and simply print the contents of the data field:

 mxfile %3Cmxfile%20host%3D%22app.diagrams.net%22%20modified%3D%222021-05-24T09%3A22%3A42.489Z%22%20agent%3D%225.0%20
(Macintosh%3B%20Intel%20Mac%20OS%20X%2010_15_7)....

That's a bunch of gibberish. Consulting the specification tells us that the data field for tEXt consists of a key and value pair separated by a null-byte. That should be easy to parse:

key, value = split(String(Char.(c.data)), '\0')

But that's only half the equation. It looks like the value part may be URL encoded and so we need to find a way to decode it. I couldn't find this functionality in the standard library and so I ended up installing URLParser.jl which implements unescape.

(@v1.6) pkg> add URLParser

Putting everything together we can complete our datastr function by adding tEXt handling:

    elseif type(c) == "tEXt"
        key, value = split(String(Char.(c.data)), '\0')
        value = unescape(value)
        return "$key, $value"
    end

And so the final output is:

13      IHDR    h=121, w=201, d=8, color type=6, compression method=0, filter method=0, interlace method=0
970     tEXt    mxfile, <mxfile host="app.diagrams.net" modified="2021-05-24T09:22:42.489Z" agent="5.0 (Maci
ntosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36" et
ag="MSmUq0enpJxQ3pDGyP_L" version="14.3.0"><diagram id="py8BCTe_me7SkJGnhe6H" name="Page-1">zZRNb4MwDEB/DcdJ
EDbaHruOdYdNm9TDdo2IC5kCRsF89dcvjFCKWKvtUGmXijw7dfxwcPxN2mw1z5MXFKAc5orG8R8cxpZLZn470FqwcHsQayl65I1gJw9g4ZBW
SgHFJJEQFcl8CiPMMohowrjWWE/T9qimVXMewwzsIq7m9F0KSiz1gtUYeAIZJ7b0ki36QMqHZNtJkXCB9QnyQ8ffaETqn9JmA6pzN3jp9z2e
iR4PpiGj32wItquqWN+tgjC+3fPXj0Ks4cbv/6XiqrQN28NSOxgAYYTYJWpKMMaMq3Ck9xrLTEBXxjWrMecZMTfQM/ATiFr7dnlJaFBCqbLR
vmZX6GxvFhVY6gguNDTMCNcx0IU8dnwDZnIBUyDdmn0aFCdZTc/B7QzFx7xRs3mwpv9g3ZtZX6f8ILN4Jn9U23mqE0mwy/m3gdrct580VqAJ
mssi543bDcy102qvqzdc1/pk+IeJTk7mPnCv5IrNXL0ppOLfmfK965kyy/E78R07+dj64Rc=</diagram></mxfile>
3379    IDAT
0       IEND

The secret to how diagrams.net embeds the diagram is solved. It's urlencoded XML embedded into a tEXt chunk inside the PNG file (now that's a fun sentence to say!).

The full code can be found at https://github.com/halfdan/geekmonkey/tree/main/julia/lwm-03

Summary

In this article, we've covered a lot of different concepts in Julia. If you struggled to keep up – don't worry I'll go over all the concepts mentioned here in more detail in future posts. My approach to learning is often guided by the projects I want to do and so I often jump in at the deep end. As a result, this article introduced concepts rather rapidly without spending too much time on the mechanics.

It's always fascinating when you think about how many things we take for granted in tech without thinking about the underlying mechanics. I was definitely surprised by how easy it was to extract some metadata from a binary format like PNG. I've used PNG files for decades without ever thinking about their inner structure. Clearly, we've only scratched the surface and haven't looked at the IDAT chunk containing all the image information, but we'll get there with time.

💡
If you like articles like this one, please consider subscribing to my free newsletter where at least once a week I send out my latest work covering Julia, Python, Machine Learning, and other tech.

You can also follow me on Twitter.

Advanced reshaping in DataFrames.jl

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2021/05/28/pivot.html

In DataFrames.jl currently you have two functions that you can use
for reshaping your data: stack and unstack. Their design goals are very
simple:

  • stack allows you to go from wide to long data format;
  • unstck works the other way around and takes data in long format producing
    a wide table.

In this post I want to focus on the unstack function. Starting from explaining
its basic usage and then covering three common more complex scenarios.

This post was written under Julia 1.6.1 and DataFrames.jl 1.1.1.

Preparing the data

Consider the following data
(and practice a bit basic data transformation skills):

julia> using DataFrames

julia> sales = DataFrame(year=repeat(2001:2003, inner=4),
                         quarter=repeat(1:4, outer=3),
                         north=1:12, south=21:32)
12×4 DataFrame
 Row │ year   quarter  north  south
     │ Int64  Int64    Int64  Int64
─────┼──────────────────────────────
   1 │  2001        1      1     21
   2 │  2001        2      2     22
   3 │  2001        3      3     23
   4 │  2001        4      4     24
   5 │  2002        1      5     25
   6 │  2002        2      6     26
   7 │  2002        3      7     27
   8 │  2002        4      8     28
   9 │  2003        1      9     29
  10 │  2003        2     10     30
  11 │  2003        3     11     31
  12 │  2003        4     12     32

julia> costs = select(sales, :year, :quarter, [:north, :south] .=> x -> x/2,
                      renamecols=false)
12×4 DataFrame
 Row │ year   quarter  north    south
     │ Int64  Int64    Float64  Float64
─────┼──────────────────────────────────
   1 │  2001        1      0.5     10.5
   2 │  2001        2      1.0     11.0
   3 │  2001        3      1.5     11.5
   4 │  2001        4      2.0     12.0
   5 │  2002        1      2.5     12.5
   6 │  2002        2      3.0     13.0
   7 │  2002        3      3.5     13.5
   8 │  2002        4      4.0     14.0
   9 │  2003        1      4.5     14.5
  10 │  2003        2      5.0     15.0
  11 │  2003        3      5.5     15.5
  12 │  2003        4      6.0     16.0

julia> long_sales = stack(sales, [:north, :south], [:year, :quarter],
                          variable_name=:region, value_name=:sales)
24×4 DataFrame
 Row │ year   quarter  region  sales
     │ Int64  Int64    String  Int64
─────┼───────────────────────────────
   1 │  2001        1  north       1
   2 │  2001        2  north       2
   3 │  2001        3  north       3
   4 │  2001        4  north       4
   5 │  2002        1  north       5
   6 │  2002        2  north       6
   7 │  2002        3  north       7
   8 │  2002        4  north       8
   9 │  2003        1  north       9
  10 │  2003        2  north      10
  11 │  2003        3  north      11
  12 │  2003        4  north      12
  13 │  2001        1  south      21
  14 │  2001        2  south      22
  15 │  2001        3  south      23
  16 │  2001        4  south      24
  17 │  2002        1  south      25
  18 │  2002        2  south      26
  19 │  2002        3  south      27
  20 │  2002        4  south      28
  21 │  2003        1  south      29
  22 │  2003        2  south      30
  23 │  2003        3  south      31
  24 │  2003        4  south      32

julia> long_costs = stack(costs, [:north, :south], [:year, :quarter],
                          variable_name=:region, value_name=:costs)
24×4 DataFrame
 Row │ year   quarter  region  costs
     │ Int64  Int64    String  Float64
─────┼─────────────────────────────────
   1 │  2001        1  north       0.5
   2 │  2001        2  north       1.0
   3 │  2001        3  north       1.5
   4 │  2001        4  north       2.0
   5 │  2002        1  north       2.5
   6 │  2002        2  north       3.0
   7 │  2002        3  north       3.5
   8 │  2002        4  north       4.0
   9 │  2003        1  north       4.5
  10 │  2003        2  north       5.0
  11 │  2003        3  north       5.5
  12 │  2003        4  north       6.0
  13 │  2001        1  south      10.5
  14 │  2001        2  south      11.0
  15 │  2001        3  south      11.5
  16 │  2001        4  south      12.0
  17 │  2002        1  south      12.5
  18 │  2002        2  south      13.0
  19 │  2002        3  south      13.5
  20 │  2002        4  south      14.0
  21 │  2003        1  south      14.5
  22 │  2003        2  south      15.0
  23 │  2003        3  south      15.5
  24 │  2003        4  south      16.0

julia> long = innerjoin(long_sales, long_costs, on=[:year, :quarter, :region])
24×5 DataFrame
 Row │ year   quarter  region  sales  costs
     │ Int64  Int64    String  Int64  Float64
─────┼────────────────────────────────────────
   1 │  2001        1  north       1      0.5
   2 │  2001        2  north       2      1.0
   3 │  2001        3  north       3      1.5
   4 │  2001        4  north       4      2.0
   5 │  2002        1  north       5      2.5
   6 │  2002        2  north       6      3.0
   7 │  2002        3  north       7      3.5
   8 │  2002        4  north       8      4.0
   9 │  2003        1  north       9      4.5
  10 │  2003        2  north      10      5.0
  11 │  2003        3  north      11      5.5
  12 │  2003        4  north      12      6.0
  13 │  2001        1  south      21     10.5
  14 │  2001        2  south      22     11.0
  15 │  2001        3  south      23     11.5
  16 │  2001        4  south      24     12.0
  17 │  2002        1  south      25     12.5
  18 │  2002        2  south      26     13.0
  19 │  2002        3  south      27     13.5
  20 │  2002        4  south      28     14.0
  21 │  2003        1  south      29     14.5
  22 │  2003        2  south      30     15.0
  23 │  2003        3  south      31     15.5
  24 │  2003        4  south      32     16.0

The basics of unstack

Assume we want to get the sales table back. We need to unstack our long
table putting :year and :quarter in rows and :region in columns, while
taking :sales as values:

julia> unstack(long, [:year, :quarter], :region, :sales)
12×4 DataFrame
 Row │ year   quarter  north   south
     │ Int64  Int64    Int64?  Int64?
─────┼────────────────────────────────
   1 │  2001        1       1      21
   2 │  2001        2       2      22
   3 │  2001        3       3      23
   4 │  2001        4       4      24
   5 │  2002        1       5      25
   6 │  2002        2       6      26
   7 │  2002        3       7      27
   8 │  2002        4       8      28
   9 │  2003        1       9      29
  10 │  2003        2      10      30
  11 │  2003        3      11      31
  12 │  2003        4      12      32

We also check that we have recovered what we wanted:

julia> unstack(long, [:year, :quarter], :region, :sales) == sales
true

However, now try to put only :year in rows. If we want to drop :quarter then
we get:

julia> unstack(long, :year, :region, :sales)
ERROR: ArgumentError: Duplicate entries in unstack at row 2 for key (2001,) and variable north. Pass allowduplicates=true to allow them.

julia> unstack(long, :year, :region, :sales, allowduplicates=true)
3×3 DataFrame
 Row │ year   north   south
     │ Int64  Int64?  Int64?
─────┼───────────────────────
   1 │  2001       4      24
   2 │  2002       8      28
   3 │  2003      12      32

Clearly even if we pass allowduplicates=true we do not get what we most likely
wanted. This leads us to the first case.

Pivot tables with unstack

Most likely we want to aggregate sales per year using the sum function. This
is a classic pivot table task. In DataFrames.jl currently one does it in two
steps: first aggregate, then reshape. Here is how you can do it (I am showing
two separate steps, but you could use e.g. Chain.jl to streamline
the processing):

julia> tmp1 = combine(groupby(long, [:year, :region]), :sales => sum => :sales)
6×3 DataFrame
 Row │ year   region  sales
     │ Int64  String  Int64
─────┼──────────────────────
   1 │  2001  north      10
   2 │  2001  south      90
   3 │  2002  north      26
   4 │  2002  south     106
   5 │  2003  north      42
   6 │  2003  south     122

julia> unstack(tmp1, :year, :region, :sales)
3×3 DataFrame
 Row │ year   north   south
     │ Int64  Int64?  Int64?
─────┼───────────────────────
   1 │  2001      10      90
   2 │  2002      26     106
   3 │  2003      42     122

Multiple variables put in columns

What if we wanted to put only :year in rows, but both :quarter and :region
in columns?

In this case we need to create a temporary column which we combine the
:quarter and :region. Here is a simple example:

julia> tmp2 = transform(long, [:region, :quarter] => ByRow(string) => :rq)
24×6 DataFrame
 Row │ year   quarter  region  sales  costs    rq
     │ Int64  Int64    String  Int64  Float64  String
─────┼────────────────────────────────────────────────
   1 │  2001        1  north       1      0.5  north1
   2 │  2001        2  north       2      1.0  north2
   3 │  2001        3  north       3      1.5  north3
   4 │  2001        4  north       4      2.0  north4
   5 │  2002        1  north       5      2.5  north1
   6 │  2002        2  north       6      3.0  north2
   7 │  2002        3  north       7      3.5  north3
   8 │  2002        4  north       8      4.0  north4
   9 │  2003        1  north       9      4.5  north1
  10 │  2003        2  north      10      5.0  north2
  11 │  2003        3  north      11      5.5  north3
  12 │  2003        4  north      12      6.0  north4
  13 │  2001        1  south      21     10.5  south1
  14 │  2001        2  south      22     11.0  south2
  15 │  2001        3  south      23     11.5  south3
  16 │  2001        4  south      24     12.0  south4
  17 │  2002        1  south      25     12.5  south1
  18 │  2002        2  south      26     13.0  south2
  19 │  2002        3  south      27     13.5  south3
  20 │  2002        4  south      28     14.0  south4
  21 │  2003        1  south      29     14.5  south1
  22 │  2003        2  south      30     15.0  south2
  23 │  2003        3  south      31     15.5  south3
  24 │  2003        4  south      32     16.0  south4

julia> unstack(tmp2, :year, :rq, :sales)
3×9 DataFrame
 Row │ year   north1  north2  north3  north4  south1  south2  south3  south4
     │ Int64  Int64?  Int64?  Int64?  Int64?  Int64?  Int64?  Int64?  Int64?
─────┼───────────────────────────────────────────────────────────────────────
   1 │  2001       1       2       3       4      21      22      23      24
   2 │  2002       5       6       7       8      25      26      27      28
   3 │  2003       9      10      11      12      29      30      31      32

Note that this additional step is only required for columns as for rows
unstack accepts multiple columns as shown above.

Multiple value variables

Now we get to my favorite Chekhov’s gun element of our story. Why do we
have a :costs column in our long table? The reason is that now we will
discuss how one can unstack a data frame on multiple value columns.

Here you have three options how you want to store the values:

  • use a nested field;
  • stack them vertically;
  • merge them horizontally.

Let me now discuss the three options. Nesting the field can be done e.g.
in the following way:

julia> tmp3 = transform(long, AsTable([:sales, :costs]) =>
                              ByRow(identity) =>
                              :indicators)
24×6 DataFrame
 Row │ year   quarter  region  sales  costs    indicators
     │ Int64  Int64    String  Int64  Float64  NamedTupl…
─────┼────────────────────────────────────────────────────────────────────
   1 │  2001        1  north       1      0.5  (sales = 1, costs = 0.5)
   2 │  2001        2  north       2      1.0  (sales = 2, costs = 1.0)
   3 │  2001        3  north       3      1.5  (sales = 3, costs = 1.5)
   4 │  2001        4  north       4      2.0  (sales = 4, costs = 2.0)
   5 │  2002        1  north       5      2.5  (sales = 5, costs = 2.5)
   6 │  2002        2  north       6      3.0  (sales = 6, costs = 3.0)
   7 │  2002        3  north       7      3.5  (sales = 7, costs = 3.5)
   8 │  2002        4  north       8      4.0  (sales = 8, costs = 4.0)
   9 │  2003        1  north       9      4.5  (sales = 9, costs = 4.5)
  10 │  2003        2  north      10      5.0  (sales = 10, costs = 5.0)
  11 │  2003        3  north      11      5.5  (sales = 11, costs = 5.5)
  12 │  2003        4  north      12      6.0  (sales = 12, costs = 6.0)
  13 │  2001        1  south      21     10.5  (sales = 21, costs = 10.5)
  14 │  2001        2  south      22     11.0  (sales = 22, costs = 11.0)
  15 │  2001        3  south      23     11.5  (sales = 23, costs = 11.5)
  16 │  2001        4  south      24     12.0  (sales = 24, costs = 12.0)
  17 │  2002        1  south      25     12.5  (sales = 25, costs = 12.5)
  18 │  2002        2  south      26     13.0  (sales = 26, costs = 13.0)
  19 │  2002        3  south      27     13.5  (sales = 27, costs = 13.5)
  20 │  2002        4  south      28     14.0  (sales = 28, costs = 14.0)
  21 │  2003        1  south      29     14.5  (sales = 29, costs = 14.5)
  22 │  2003        2  south      30     15.0  (sales = 30, costs = 15.0)
  23 │  2003        3  south      31     15.5  (sales = 31, costs = 15.5)
  24 │  2003        4  south      32     16.0  (sales = 32, costs = 16.0)

julia> unstack(tmp3, [:year, :quarter], :region, :indicators)
12×4 DataFrame
 Row │ year   quarter  north                      south
     │ Int64  Int64    NamedTup…?                 NamedTup…?
─────┼───────────────────────────────────────────────────────────────────────
   1 │  2001        1  (sales = 1, costs = 0.5)   (sales = 21, costs = 10.5)
   2 │  2001        2  (sales = 2, costs = 1.0)   (sales = 22, costs = 11.0)
   3 │  2001        3  (sales = 3, costs = 1.5)   (sales = 23, costs = 11.5)
   4 │  2001        4  (sales = 4, costs = 2.0)   (sales = 24, costs = 12.0)
   5 │  2002        1  (sales = 5, costs = 2.5)   (sales = 25, costs = 12.5)
   6 │  2002        2  (sales = 6, costs = 3.0)   (sales = 26, costs = 13.0)
   7 │  2002        3  (sales = 7, costs = 3.5)   (sales = 27, costs = 13.5)
   8 │  2002        4  (sales = 8, costs = 4.0)   (sales = 28, costs = 14.0)
   9 │  2003        1  (sales = 9, costs = 4.5)   (sales = 29, costs = 14.5)
  10 │  2003        2  (sales = 10, costs = 5.0)  (sales = 30, costs = 15.0)
  11 │  2003        3  (sales = 11, costs = 5.5)  (sales = 31, costs = 15.5)
  12 │  2003        4  (sales = 12, costs = 6.0)  (sales = 32, costs = 16.0)

The second option is vertical stacking:

julia> vcat(unstack(long, [:year, :quarter], :region, :sales),
            unstack(long, [:year, :quarter], :region, :costs),
            source=:indicator=>["sales", "costs"])
24×5 DataFrame
 Row │ year   quarter  north     south     indicator
     │ Int64  Int64    Float64?  Float64?  String
─────┼───────────────────────────────────────────────
   1 │  2001        1       1.0      21.0  sales
   2 │  2001        2       2.0      22.0  sales
   3 │  2001        3       3.0      23.0  sales
   4 │  2001        4       4.0      24.0  sales
   5 │  2002        1       5.0      25.0  sales
   6 │  2002        2       6.0      26.0  sales
   7 │  2002        3       7.0      27.0  sales
   8 │  2002        4       8.0      28.0  sales
   9 │  2003        1       9.0      29.0  sales
  10 │  2003        2      10.0      30.0  sales
  11 │  2003        3      11.0      31.0  sales
  12 │  2003        4      12.0      32.0  sales
  13 │  2001        1       0.5      10.5  costs
  14 │  2001        2       1.0      11.0  costs
  15 │  2001        3       1.5      11.5  costs
  16 │  2001        4       2.0      12.0  costs
  17 │  2002        1       2.5      12.5  costs
  18 │  2002        2       3.0      13.0  costs
  19 │  2002        3       3.5      13.5  costs
  20 │  2002        4       4.0      14.0  costs
  21 │  2003        1       4.5      14.5  costs
  22 │  2003        2       5.0      15.0  costs
  23 │  2003        3       5.5      15.5  costs
  24 │  2003        4       6.0      16.0  costs

or

julia> unstack(stack(long, [:sales, :costs], [:year, :quarter, :region],
                     variable_name=:indicator),
               [:year, :quarter, :indicator], :region, :value)
24×5 DataFrame
 Row │ year   quarter  indicator  north     south
     │ Int64  Int64    String     Float64?  Float64?
─────┼───────────────────────────────────────────────
   1 │  2001        1  sales           1.0      21.0
   2 │  2001        2  sales           2.0      22.0
   3 │  2001        3  sales           3.0      23.0
   4 │  2001        4  sales           4.0      24.0
   5 │  2002        1  sales           5.0      25.0
   6 │  2002        2  sales           6.0      26.0
   7 │  2002        3  sales           7.0      27.0
   8 │  2002        4  sales           8.0      28.0
   9 │  2003        1  sales           9.0      29.0
  10 │  2003        2  sales          10.0      30.0
  11 │  2003        3  sales          11.0      31.0
  12 │  2003        4  sales          12.0      32.0
  13 │  2001        1  costs           0.5      10.5
  14 │  2001        2  costs           1.0      11.0
  15 │  2001        3  costs           1.5      11.5
  16 │  2001        4  costs           2.0      12.0
  17 │  2002        1  costs           2.5      12.5
  18 │  2002        2  costs           3.0      13.0
  19 │  2002        3  costs           3.5      13.5
  20 │  2002        4  costs           4.0      14.0
  21 │  2003        1  costs           4.5      14.5
  22 │  2003        2  costs           5.0      15.0
  23 │  2003        3  costs           5.5      15.5
  24 │  2003        4  costs           6.0      16.0

Finally we might want to perform horizontal merging which can be done e.g. like
this:

julia> outerjoin(unstack(long, [:year, :quarter], :region, :sales),
                 unstack(long, [:year, :quarter], :region, :costs),
                 on=[:year, :quarter], renamecols="_sales" => "_costs")
12×6 DataFrame
 Row │ year   quarter  north_sales  south_sales  north_costs  south_costs
     │ Int64  Int64    Int64?       Int64?       Float64?     Float64?
─────┼────────────────────────────────────────────────────────────────────
   1 │  2001        1            1           21          0.5         10.5
   2 │  2001        2            2           22          1.0         11.0
   3 │  2001        3            3           23          1.5         11.5
   4 │  2001        4            4           24          2.0         12.0
   5 │  2002        1            5           25          2.5         12.5
   6 │  2002        2            6           26          3.0         13.0
   7 │  2002        3            7           27          3.5         13.5
   8 │  2002        4            8           28          4.0         14.0
   9 │  2003        1            9           29          4.5         14.5
  10 │  2003        2           10           30          5.0         15.0
  11 │  2003        3           11           31          5.5         15.5
  12 │  2003        4           12           32          6.0         16.0

Concluding remmarks

Today I have focused mostly on the unstack function, and only mentioned
stack in a few places.

However, it is also worth to know that there are two other functions that are
very often handy and easy to forget about. One is good old permutedims
(transposing a data frame) and the other is flatten(flattening nested
columns). If you want to widen your DataFrames.jl related arsenal of tricks I
recommend you to check out their documentation.