File size: 11,658 Bytes
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// Copyright 2026 Everpaper. Licensed under the Apache License, Version 2.0 (see ../../../LICENSE).
// The CMYK -> RGB conversion is ported from Pillow's formula (MIT-CMU/HPND); see ../../../NOTICE for its notice.
//
// Decodes an image file (or a CGImage) to 8-bit RGB the way the upstream pipeline does (`decode_image` /
// Pillow `convert("RGB")`): the stored samples are used as they are — alpha is DROPPED (not composited, not
// un-premultiplied), EXIF orientation is NOT applied unless asked, no colour management (ICC profiles are
// ignored). Core Graphics drawing would premultiply, composite and colour-match, so the samples are read straight
// from the decoded CGImage buffer and unpacked by hand (RGB/RGBA/gray/gray+alpha/indexed/CMYK, 1-16 bits per
// component); anything else falls back to a Core Graphics draw into an RGB bitmap (documented tolerance).
//
// PNG and WebP decode to the same bytes as Pillow/libpng. JPEG does not: ImageIO's decoder differs from libjpeg
// by 1-3 per sample without chroma subsampling and by up to ~80 at colour edges of 4:2:0 files (different chroma
// upsampling). Embedding effect: cosine >= 0.998 on camera photos, down to ~0.98 on 4:2:0 JPEGs of charts with
// saturated colour edges (see README "Accuracy"). Decode with libjpeg if reference-exact JPEG vectors are needed.

import CoreGraphics
import Foundation
import ImageIO

public struct DecodedImage: Sendable {
    public var width: Int
    public var height: Int
    public var rgb: [UInt8]   // interleaved, row-major, width * height * 3

    public init(width: Int, height: Int, rgb: [UInt8]) {
        self.width = width
        self.height = height
        self.rgb = rgb
    }
}

public enum ImageDecoder {
    /// Decodes the first image of a file. `applyEXIFOrientation` rotates/flips to the display orientation
    /// (the upstream reference does not; see README).
    public static func decodeRGB(url: URL, applyEXIFOrientation: Bool = false) throws -> DecodedImage {
        guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else {
            throw EmbedderError(description: "cannot open image \(url.path)")
        }
        guard let cg = CGImageSourceCreateImageAtIndex(src, 0, [kCGImageSourceShouldCache: false] as CFDictionary) else {
            throw EmbedderError(description: "cannot decode image \(url.path)")
        }
        var img = try decodeRGB(cgImage: cg)
        if applyEXIFOrientation,
           let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
           let o = props[kCGImagePropertyOrientation] as? UInt32, let orientation = CGImagePropertyOrientation(rawValue: o) {
            img = oriented(img, orientation)
        }
        return img
    }

    /// Raw samples of a CGImage (e.g. a screenshot) as RGB, alpha dropped. See the header for the rules.
    public static func decodeRGB(cgImage cg: CGImage) throws -> DecodedImage {
        let w = cg.width, h = cg.height
        let bpc = cg.bitsPerComponent, bpp = cg.bitsPerPixel, bpr = cg.bytesPerRow
        let model = cg.colorSpace?.model
        let byteOrder = cg.bitmapInfo.intersection(.byteOrderMask)
        let alpha = cg.alphaInfo
        let isFloat = cg.bitmapInfo.contains(.floatComponents)
        guard let data = cg.dataProvider?.data, let base = CFDataGetBytePtr(data), !isFloat, let model else {
            return try drawFallback(cg)
        }
        let n = CFDataGetLength(data)
        guard n >= bpr * h else { return try drawFallback(cg) }
        var out = [UInt8](repeating: 0, count: w * h * 3)
        let components: Int
        switch model {
        case .rgb: components = 3
        case .monochrome: components = 1
        case .cmyk: components = 4
        case .indexed: components = 1
        default: return try drawFallback(cg)
        }
        let hasAlpha = ![CGImageAlphaInfo.none, .noneSkipLast, .noneSkipFirst].contains(alpha) || (model == .rgb && bpp == 32 && bpc == 8)
        let alphaFirst = alpha == .first || alpha == .premultipliedFirst || alpha == .noneSkipFirst
        let channelsInPixel = bpp / bpc
        guard channelsInPixel == components || channelsInPixel == components + 1 else { return try drawFallback(cg) }
        let little16 = byteOrder == .byteOrder16Little
        let little32 = byteOrder == .byteOrder32Little

        // Sample accessor for 8/16-bit packed pixels: returns the 8-bit value of channel c of pixel (x, y).
        // Memory layout: big-endian (default) = logical order; 32-bit little-endian reverses the 4 bytes.
        @inline(__always) func sample8(_ row: UnsafePointer<UInt8>, _ x: Int, _ c: Int) -> UInt8 {
            var ch = c + (hasAlpha && alphaFirst ? 1 : 0)
            if bpc == 8 {
                if little32 && channelsInPixel == 4 { ch = 3 - ch }
                return row[x * channelsInPixel + ch]
            }
            // 16 bits per component: high byte (PIL's convert clips >255 to 255; upstream divides by 255 and
            // produces garbage — neither is useful, the high byte is the sensible 8-bit rendering)
            let p = row + (x * channelsInPixel + ch) * 2
            return little16 ? p[1] : p[0]
        }
        if model == .indexed {
            var table = [UInt8](repeating: 0, count: 256 * 3)
            let count = cg.colorSpace?.colorTable?.count ?? 0   // RGB triples
            if let ct = cg.colorSpace?.colorTable, count > 0 {
                for i in 0..<min(count, 256 * 3) { table[i] = ct[i] }
            } else {
                return try drawFallback(cg)
            }
            for y in 0..<h {
                let row = base + y * bpr
                for x in 0..<w {
                    let idx = Int(unpack(row, x, bitsPerComponent: bpc, channel: 0, channels: channelsInPixel))
                    let i = min(idx, 255) * 3
                    out[(y * w + x) * 3] = table[i]
                    out[(y * w + x) * 3 + 1] = table[i + 1]
                    out[(y * w + x) * 3 + 2] = table[i + 2]
                }
            }
            return DecodedImage(width: w, height: h, rgb: out)
        }
        if bpc < 8 {   // 1/2/4-bit gray (bilevel PNG): scale to 0...255 like libpng/PIL ("1" -> 0/255)
            guard model == .monochrome else { return try drawFallback(cg) }
            let maxv = (1 << bpc) - 1
            for y in 0..<h {
                let row = base + y * bpr
                for x in 0..<w {
                    let v = UInt8(Int(unpack(row, x, bitsPerComponent: bpc, channel: 0, channels: channelsInPixel)) * 255 / maxv)
                    out[(y * w + x) * 3] = v; out[(y * w + x) * 3 + 1] = v; out[(y * w + x) * 3 + 2] = v
                }
            }
            return DecodedImage(width: w, height: h, rgb: out)
        }
        guard bpc == 8 || bpc == 16 else { return try drawFallback(cg) }
        for y in 0..<h {
            let row = base + y * bpr
            let o = y * w * 3
            switch model {
            case .rgb:
                for x in 0..<w {
                    out[o + x * 3] = sample8(row, x, 0)
                    out[o + x * 3 + 1] = sample8(row, x, 1)
                    out[o + x * 3 + 2] = sample8(row, x, 2)
                }
            case .monochrome:
                for x in 0..<w {
                    let v = sample8(row, x, 0)
                    out[o + x * 3] = v; out[o + x * 3 + 1] = v; out[o + x * 3 + 2] = v
                }
            case .cmyk:   // Pillow's CMYK -> RGB: nk = 255 - k; c' = nk - c*nk/255 (MULDIV255 rounding). ImageIO's
                          // samples are the inverse of Pillow's ink values (verified on an Adobe CMYK JPEG), so invert.
                for x in 0..<w {
                    let c = 255 - Int(sample8(row, x, 0)), m = 255 - Int(sample8(row, x, 1)), yy = 255 - Int(sample8(row, x, 2)), k = 255 - Int(sample8(row, x, 3))
                    let nk = 255 - k
                    @inline(__always) func mul(_ a: Int) -> Int { let t = a * nk + 128; return ((t >> 8) + t) >> 8 }
                    out[o + x * 3] = UInt8(clamping: nk - mul(c))
                    out[o + x * 3 + 1] = UInt8(clamping: nk - mul(m))
                    out[o + x * 3 + 2] = UInt8(clamping: nk - mul(yy))
                }
            default:
                return try drawFallback(cg)
            }
        }
        return DecodedImage(width: w, height: h, rgb: out)
    }

    @inline(__always) private static func unpack(_ row: UnsafePointer<UInt8>, _ x: Int, bitsPerComponent bpc: Int, channel: Int, channels: Int) -> UInt32 {
        if bpc == 8 { return UInt32(row[x * channels + channel]) }
        if bpc == 16 { return UInt32(row[(x * channels + channel) * 2]) }
        let bit = (x * channels + channel) * bpc
        let byte = row[bit >> 3]
        let shift = 8 - bpc - (bit & 7)
        return UInt32((byte >> UInt8(shift)) & UInt8((1 << bpc) - 1))
    }

    /// Core Graphics draw (colour-managed, alpha composited over black): the fallback for exotic formats.
    private static func drawFallback(_ cg: CGImage) throws -> DecodedImage {
        let w = cg.width, h = cg.height
        var buf = [UInt8](repeating: 0, count: w * h * 4)
        let ok = buf.withUnsafeMutableBytes { p -> Bool in
            guard let ctx = CGContext(data: p.baseAddress, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
                                      space: CGColorSpaceCreateDeviceRGB(),
                                      bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) else { return false }
            ctx.interpolationQuality = .none
            ctx.draw(cg, in: CGRect(x: 0, y: 0, width: w, height: h))
            return true
        }
        guard ok else { throw EmbedderError(description: "cannot draw image (\(cg.bitsPerComponent) bpc, \(cg.bitsPerPixel) bpp)") }
        var out = [UInt8](repeating: 0, count: w * h * 3)
        for i in 0..<(w * h) {
            out[i * 3] = buf[i * 4]; out[i * 3 + 1] = buf[i * 4 + 1]; out[i * 3 + 2] = buf[i * 4 + 2]
        }
        return DecodedImage(width: w, height: h, rgb: out)
    }

    /// Applies an EXIF orientation (1...8) to an RGB buffer (Pillow `ImageOps.exif_transpose`).
    public static func oriented(_ img: DecodedImage, _ o: CGImagePropertyOrientation) -> DecodedImage {
        let w = img.width, h = img.height
        let swap = [CGImagePropertyOrientation.left, .right, .leftMirrored, .rightMirrored].contains(o)
        let ow = swap ? h : w, oh = swap ? w : h
        if o == .up { return img }
        var out = [UInt8](repeating: 0, count: ow * oh * 3)
        for y in 0..<h {
            for x in 0..<w {
                let (dx, dy): (Int, Int)
                switch o {
                case .up: (dx, dy) = (x, y)
                case .upMirrored: (dx, dy) = (w - 1 - x, y)
                case .down: (dx, dy) = (w - 1 - x, h - 1 - y)
                case .downMirrored: (dx, dy) = (x, h - 1 - y)
                case .leftMirrored: (dx, dy) = (y, x)                   // transpose
                case .right: (dx, dy) = (h - 1 - y, x)                  // rotate 90 CW to display
                case .rightMirrored: (dx, dy) = (h - 1 - y, w - 1 - x)  // transverse
                case .left: (dx, dy) = (y, w - 1 - x)                   // rotate 90 CCW
                @unknown default: (dx, dy) = (x, y)
                }
                let s = (y * w + x) * 3, d = (dy * ow + dx) * 3
                out[d] = img.rgb[s]; out[d + 1] = img.rgb[s + 1]; out[d + 2] = img.rgb[s + 2]
            }
        }
        return DecodedImage(width: ow, height: oh, rgb: out)
    }
}