// 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, _ 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.. 0/255) guard model == .monochrome else { return try drawFallback(cg) } let maxv = (1 << bpc) - 1 for y in 0.. 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.. 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, _ 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..