DEV Community

Joachim Rütter
Joachim Rütter

Posted on

Why I stopped letting Apple’s ImageIO open huge PSDs: a fast native PSB reader in Swift 6

The Hubble Space Telescope’s Andromeda mosaic is published as a Photoshop Large Document
(heic1502a.psb): 69,536 × 22,230 pixels, 4.6 GB on disk. I wanted my image viewer,
Featherview, to show it the way it shows a
holiday JPEG: press a key, see the picture.

Apple’s ImageIO can open it. Asking it for a screen-sized thumbnail
(CGImageSourceCreateThumbnailAtIndex, 3024 px max) on an Apple Silicon Mac took:

ImageIO Our reader
Fit to a 3024 × 1964 screen, first run 7.4 s 0.73 s
Same, file already in the page cache 4.1 s 0.23 s
A 100 % part of the centre (6048 × 3928, Retina) (no region API) 0.06 s

The short version:

  • Skip everything but the flattened picture. Every section before it says how long it is.
  • Use the RLE row table to jump straight to the rows you need.
  • Average down to screen size while reading, in parallel, so the full image never sits in memory.

Featherview still uses ImageIO for almost everything. This reader covers one case ImageIO handles
badly: huge 8-bit RGB PSD/PSB files. Here’s how it works, with the real code.

1. A PSD is a chain of length-prefixed sections

The format is old and simple. After a 26-byte header come four sections, each starting with its length:

"8BPS"  version(2)  reserved(6)  channels(2)  height(4)  width(4)  depth(2)  mode(2)
colour mode data     length(4) + bytes
image resources      length(4) + bytes
layer and mask info  length(4 in PSD, 8 in PSB) + bytes   ← can be gigabytes
image data           compression(2) + the flattened composite
Enter fullscreen mode Exit fullscreen mode

PSB (“large document”, version 2) changes only a few things: sides up to 300,000 px instead of
30,000, an 8-byte length for the layer section, and 4-byte instead of 2-byte RLE row counts.

The key point: the flattened picture sits at the end, and every section before it says how long
it is.
We never parse the layers. We read the header, three lengths and the resource list, then
jump straight to the image data:

guard let header = bytes(0, 26), header[0..<4].elementsEqual("8BPS".utf8) else { return nil }
let version = be(header[4..<6]), channels = be(header[12..<14])
let height = be(header[14..<18]), width = be(header[18..<22])
let depth = be(header[22..<24]), mode = be(header[24..<26])
// PSB allows at most 300,000 per side, which also keeps decode's 32-bit row sums from overflowing.
guard version == 1 || version == 2, channels == 3, depth == 8, mode == 3, (1...300_000).contains(width),
      (1...300_000).contains(height) else { return nil }
Enter fullscreen mode Exit fullscreen mode

bytes(offset, count) is a pread that refuses any range past the end of the file.

A damaged length field can claim anything. So each one is checked against the file size before
anything is allocated. A corrupt file is turned away instead of asking for a 40 GB buffer.

The trap: the composite might not be the picture

Save from Photoshop with “Maximize Compatibility” turned off, and the stored composite is only a
placeholder. The real picture exists only in the layers.

One byte in image resource 1057 says so. If it’s 0, this reader declines, and Featherview 0.13 draws
the picture from the layers instead
(how that was checked against Photoshop):

if id == 1039 { icc = Data(resources[sizeAt + 4 ..< sizeAt + 4 + size]) }
// Version info: saved without Maximize Compatibility, the stored composite isn't the picture.
if id == 1057, size >= 5, resources[sizeAt + 8] == 0 { return nil }
Enter fullscreen mode Exit fullscreen mode

Resource 1039 is the embedded ICC profile, kept so colours convert correctly later.

2. PackBits RLE gives you random access, if you read the table

The composite is stored planar: all red rows, then all green, then all blue. Each row is raw or
PackBits-compressed.

With RLE, the image data starts with a table of byte counts, one per row per channel. Add them up
once when the file opens, and you know where each of the 3 × 22,230 rows starts:

var offsets = [Int](repeating: 0, count: rows + 1)
offsets[0] = offset + rows * countSize
for i in 0..<rows { offsets[i + 1] = offsets[i] + be(table[i * countSize ..< (i + 1) * countSize]) }
guard offsets[rows] <= fileSize else { return nil }
Enter fullscreen mode Exit fullscreen mode

That table turns a 4.6 GB blob into something you can seek in. To show rows 10,000 to 14,000, read
exactly those rows’ bytes and nothing else.

Unpacking is the classic PackBits loop, with two changes: it stops at the region’s right edge
(no point unpacking 69,536 bytes to use the first 6,000), and it never trusts a run length:

static func unpackBits(_ packed: UnsafeBufferPointer<UInt8>, width: Int, into out: UnsafeMutableBufferPointer<UInt8>) throws {
    var i = 0, n = 0
    while i < packed.count, n < out.count {
        let run = Int(Int8(bitPattern: packed[i]))
        i += 1
        if run >= 0 {
            guard i + run + 1 <= packed.count, n + run + 1 <= width else { throw OwnDecodeError.corrupt }
            let take = min(run + 1, out.count - n)
            (out.baseAddress! + n).update(from: packed.baseAddress! + i, count: take)
            n += run + 1
            i += run + 1
        } else if run > -128 {
            guard i < packed.count, n + 1 - run <= width else { throw OwnDecodeError.corrupt }
            (out.baseAddress! + n).update(repeating: packed[i], count: min(1 - run, out.count - n))
            n += 1 - run
            i += 1
        }
    }
    guard n >= out.count, out.count < width || n == width else { throw OwnDecodeError.corrupt }
}
Enter fullscreen mode Exit fullscreen mode

A header byte n from 0 to 127 means “copy the next n + 1 bytes”. From −1 to −127 it means “repeat the next
byte 1 − n times”. −128 is a no-op. Every run is checked against both the input and the row width,
because a file from the internet is untrusted input.

3. Downscaling while reading: area averaging in parallel bands

The fit decode needs 3024 × 967 pixels out of 69,536 × 22,230. Each output pixel is the exact mean of
a box of about 23 × 23 source pixels. We compute it while streaming rows, so the full-size image
never exists in memory.

The column boundaries are computed once, with integer arithmetic so the boxes tile the region exactly:

let columns = (0...ow).map { x0 + $0 * rw / ow }
Enter fullscreen mode Exit fullscreen mode

Then each output row is an independent job for DispatchQueue.concurrentPerform. Each job opens
its own source rows, reads them in chunks of about 1 MB, and adds them into a per-row accumulator:

DispatchQueue.concurrentPerform(iterations: oh) { oy in
    let r0 = y0 + oy * rh / oh, r1 = y0 + (oy + 1) * rh / oh
    // ...
    // About 1 MB of rows per read, so a small decode of a huge picture holds little per band.
    let chunk = max(1, (1 << 20) / (rowOffsets == nil ? rw : width))
Enter fullscreen mode Exit fullscreen mode

The inner loop is where the time goes, so it’s kept tight:

for ox in 0..<ow {
    // One row of an output pixel fits in 32 bits: PSB rows are at most 300,000 × 255.
    var sum: UInt32 = 0
    for x in columns[ox]..<columns[ox + 1] { sum &+= UInt32(line[x]) }
    sums[ox * 3 + c] += UInt64(sum)
}
Enter fullscreen mode Exit fullscreen mode

That comment is why the header check rejects sides over 300,000. 300,000 × 255 = 76.5 million,
which fits in a UInt32. So the hot loop uses cheap 32-bit adds, and only the row total goes into
a 64-bit sum.

A limit from the format spec becomes the overflow proof.

The final pixel rounds to nearest: (sum + count / 2) / count.

Two details that mattered in the profiler:

  • One scratch buffer per output row, reused for every chunk. Allocating a fresh buffer per read cost page faults on every chunk.
  • pread with explicit offsets, no mmap. Every band reads its own rows independently with no shared file position. Only the bytes we touch get read, and memory use stays fixed however big the file is.

4. Zooming in: the same code, a smaller box

Because decode(region:size:) takes any rectangle, a 100 % zoom on a Retina screen is just a
region of 6048 × 3928 at scale 1. With RLE, rows unpack only up to the region’s right edge. With raw data,
we read only the region’s columns. On the Andromeda file that’s about 60 ms for the centre part, fast
enough to redraw after every pan.

Does it decode the same pixels?

Speed is worthless if the pixels are wrong. I collected 513 real Photoshop files and compared every one
the reader accepts against ImageIO’s full-size decode. 196 of 196 were identical, byte for byte.

The 59 it declines are exactly the files without a real stored composite. Unit tests cover truncated
files, lying lengths, and RLE runs that overflow their row.

What I learned

  • Read the format, not the framework. ImageIO is general and correct, but it isn’t built for “show me a 3,000-pixel view of a 1.5-gigapixel file”. A format with length-prefixed sections and a row table gives you random access for free.
  • Downscale while streaming. An exact area mean computed per output row in parallel never needs the full image in memory, and it’s more accurate than a filtered thumbnail.
  • Let the spec’s limits prove your arithmetic. The 300,000 px cap is what makes 32-bit sums safe.
  • Decline loudly and early. Every check that returns nil hands the file back to ImageIO. The fast path only has to be right for the files it accepts.

Featherview is a free, open-source, keyboard-driven image viewer for macOS, written in Swift 6
with strict concurrency. The reader above is
PSDComposite.swift,
about 200 lines, with tests.

👉 github.com/featherview/featherview: if you found this useful, a ⭐ helps other Mac users find it. Issues and PRs are welcome.

Top comments (0)