NYTOverlaySlicer.swift (11461B)
1 import CoreGraphics 2 import Foundation 3 import ImageIO 4 import UniformTypeIdentifiers 5 6 /// Cuts a NYT overlay asset (`overlays.afterSolve` / `overlays.beforeStart`) 7 /// into per-cell PNG tiles that can ride in an `.xd` `## Decorations` section. 8 /// 9 /// The overlay is a single transparent image the size of the whole board, so 10 /// the only way to attach it to cells is to slice it on the grid's own 11 /// geometry. That geometry is read from the puzzle's `board` SVG rather than 12 /// assumed: the NYT scales cell size to grid size — 33 units for a 15×15 13 /// (viewBox 501), 23 for a 21×21 (viewBox 489) — so a hardcoded cell size 14 /// silently misaligns every tile on a Sunday. 15 enum NYTOverlaySlicer { 16 /// Edge length a tile is stored at. A grid cell renders at roughly 72px on 17 /// an iPhone and 92px on an iPad, so 96 covers both without paying for the 18 /// ~150px the source assets ship at. 19 static let tileSize = 96 20 21 /// Alpha above which a pixel counts as ink. Comfortably above the fringe 22 /// left by antialiasing so a tile isn't kept for a neighbour's halo, and 23 /// far below anything a designer would intend to be visible. 24 static let inkAlphaThreshold: UInt8 = 32 25 26 /// Ceiling on the combined base64 payload of one overlay. `XD.maxSourceBytes` 27 /// is 256 KB and has to hold the grid, clues and both overlay phases, so a 28 /// single phase is held to well under half. An overlay past this is dropped 29 /// whole rather than truncated: a puzzle missing its art still plays, but a 30 /// puzzle whose source blows the parse cap doesn't open at all. 31 static let maxEncodedBytes = 96 * 1024 32 33 /// Where the grid sits inside the overlay's coordinate space, in SVG units, 34 /// plus the scale factor from those units to image pixels. 35 struct Geometry: Equatable { 36 let border: Double 37 let cellSize: Double 38 let viewBoxWidth: Double 39 } 40 41 /// Reads `viewBox` and the first cell rect out of a puzzle's `board` SVG. 42 /// The first `<path d="M{border} {border}h{cell}v{cell}…">` is cell (0,0), 43 /// so it yields both the margin and the cell pitch directly. 44 static func geometry(boardSVG: String) -> Geometry? { 45 guard let viewBox = boardSVG.firstMatch( 46 of: /viewBox="[\d.]+ [\d.]+ ([\d.]+) [\d.]+"/ 47 ), let viewBoxWidth = Double(viewBox.1) else { 48 return nil 49 } 50 guard let cell = boardSVG.firstMatch( 51 of: /d="M([\d.]+) [\d.]+h([\d.]+)v/ 52 ), let border = Double(cell.1), let cellSize = Double(cell.2) else { 53 return nil 54 } 55 guard viewBoxWidth > 0, cellSize > 0, border >= 0 else { return nil } 56 return Geometry(border: border, cellSize: cellSize, viewBoxWidth: viewBoxWidth) 57 } 58 59 /// Slices `imageData` into one PNG per cell that carries ink. Returns `nil` 60 /// when the image can't be read, when the derived geometry doesn't fit it, 61 /// or when the encoded result would exceed `maxEncodedBytes`. 62 static func tiles( 63 imageData: Data, 64 geometry: Geometry, 65 width: Int, 66 height: Int 67 ) -> [GridPosition: Data]? { 68 guard width > 0, height > 0 else { return nil } 69 guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), 70 let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { 71 return nil 72 } 73 74 let imageWidth = image.width 75 let imageHeight = image.height 76 let scale = Double(imageWidth) / geometry.viewBoxWidth 77 let cell = geometry.cellSize * scale 78 let border = geometry.border * scale 79 // The grid has to actually fit the image it was derived from; if it 80 // doesn't, the SVG and the asset disagree and every tile would be cut 81 // from the wrong place. 82 guard cell >= 1, 83 border + cell * Double(width) <= Double(imageWidth) + 1, 84 border + cell * Double(height) <= Double(imageHeight) + 1 else { 85 return nil 86 } 87 88 guard let pixels = argbPixels(of: image) else { return nil } 89 90 var result: [GridPosition: Data] = [:] 91 var encodedTotal = 0 92 for row in 0..<height { 93 for col in 0..<width { 94 let rect = CGRect( 95 x: border + Double(col) * cell, 96 y: border + Double(row) * cell, 97 width: cell, 98 height: cell 99 ).integral 100 guard hasInk(pixels, imageWidth: imageWidth, imageHeight: imageHeight, in: rect), 101 let tile = image.cropping(to: rect), 102 let png = pngData(scaling: tile, to: tileSize) else { 103 continue 104 } 105 // base64 is 4 bytes out for every 3 in; check as we go so a 106 // pathological overlay is abandoned early rather than after 107 // encoding hundreds of tiles. 108 encodedTotal += (png.count + 2) / 3 * 4 109 guard encodedTotal <= maxEncodedBytes else { return nil } 110 result[GridPosition(row: row, col: col)] = png 111 } 112 } 113 return result 114 } 115 116 /// Returns an XD hex colour when the interior of a stored tile is a single 117 /// RGBA fill. The thin perimeter is ignored because NYT board artwork can 118 /// repeat the grid lines Crossmate already draws. Variation in the interior 119 /// still rejects the classification, preserving letters, pieces and shapes 120 /// as image data. 121 static func uniformBackgroundHex(imageData: Data) -> String? { 122 guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), 123 let image = CGImageSourceCreateImageAtIndex(source, 0, nil), 124 let pixels = argbPixels(of: image), 125 pixels.count >= 4 else { 126 return nil 127 } 128 129 // Stored tiles are 96 px. An inset of one twelfth clears the 3–5 px 130 // board lines in NYT assets without hiding a meaningful cell symbol. 131 let inset = max(1, min(image.width, image.height) / 12) 132 guard image.width > inset * 2, image.height > inset * 2 else { return nil } 133 let referenceOffset = ( 134 (image.height / 2) * image.width + image.width / 2 135 ) * 4 136 let reference = straightRGBA( 137 red: pixels[referenceOffset], 138 green: pixels[referenceOffset + 1], 139 blue: pixels[referenceOffset + 2], 140 alpha: pixels[referenceOffset + 3] 141 ) 142 guard reference.alpha > inkAlphaThreshold else { return nil } 143 144 // One level of tolerance absorbs integer rounding when a premultiplied 145 // component is converted back to straight RGBA. It is far too small to 146 // mistake a gradient, symbol or antialiased edge for a flat fill. 147 let tolerance = 1 148 for y in inset..<(image.height - inset) { 149 for x in inset..<(image.width - inset) { 150 let offset = (y * image.width + x) * 4 151 let candidate = straightRGBA( 152 red: pixels[offset], 153 green: pixels[offset + 1], 154 blue: pixels[offset + 2], 155 alpha: pixels[offset + 3] 156 ) 157 guard abs(Int(candidate.red) - Int(reference.red)) <= tolerance, 158 abs(Int(candidate.green) - Int(reference.green)) <= tolerance, 159 abs(Int(candidate.blue) - Int(reference.blue)) <= tolerance, 160 abs(Int(candidate.alpha) - Int(reference.alpha)) <= tolerance else { 161 return nil 162 } 163 } 164 } 165 166 let rgb = String( 167 format: "#%02X%02X%02X", 168 reference.red, 169 reference.green, 170 reference.blue 171 ) 172 return reference.alpha == 255 173 ? rgb 174 : rgb + String(format: "%02X", reference.alpha) 175 } 176 177 private static func straightRGBA( 178 red: UInt8, 179 green: UInt8, 180 blue: UInt8, 181 alpha: UInt8 182 ) -> (red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8) { 183 guard alpha > 0 else { return (0, 0, 0, 0) } 184 func unpremultiply(_ component: UInt8) -> UInt8 { 185 let value = (Int(component) * 255 + Int(alpha) / 2) / Int(alpha) 186 return UInt8(clamping: value) 187 } 188 return (unpremultiply(red), unpremultiply(green), unpremultiply(blue), alpha) 189 } 190 191 /// Premultiplied RGBA bytes, four per pixel, row-major from the top left. 192 private static func argbPixels(of image: CGImage) -> [UInt8]? { 193 let width = image.width 194 let height = image.height 195 var buffer = [UInt8](repeating: 0, count: width * height * 4) 196 let drawn: Bool = buffer.withUnsafeMutableBytes { raw in 197 guard let context = CGContext( 198 data: raw.baseAddress, 199 width: width, 200 height: height, 201 bitsPerComponent: 8, 202 bytesPerRow: width * 4, 203 space: CGColorSpaceCreateDeviceRGB(), 204 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 205 ) else { 206 return false 207 } 208 context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) 209 return true 210 } 211 return drawn ? buffer : nil 212 } 213 214 private static func hasInk( 215 _ pixels: [UInt8], 216 imageWidth: Int, 217 imageHeight: Int, 218 in rect: CGRect 219 ) -> Bool { 220 let minX = max(0, Int(rect.minX)) 221 let maxX = min(imageWidth, Int(rect.maxX)) 222 let minY = max(0, Int(rect.minY)) 223 let maxY = min(imageHeight, Int(rect.maxY)) 224 guard minX < maxX, minY < maxY else { return false } 225 for y in minY..<maxY { 226 let rowStart = y * imageWidth * 4 227 for x in minX..<maxX where pixels[rowStart + x * 4 + 3] > inkAlphaThreshold { 228 return true 229 } 230 } 231 return false 232 } 233 234 /// Re-encodes a tile as PNG, scaling it down to `target` when the source is 235 /// larger. Tiles smaller than the target are left alone rather than blown 236 /// up, since upscaling adds bytes without adding detail. 237 private static func pngData(scaling tile: CGImage, to target: Int) -> Data? { 238 var image = tile 239 if tile.width > target { 240 guard let context = CGContext( 241 data: nil, 242 width: target, 243 height: target, 244 bitsPerComponent: 8, 245 bytesPerRow: 0, 246 space: CGColorSpaceCreateDeviceRGB(), 247 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 248 ) else { 249 return nil 250 } 251 context.interpolationQuality = .high 252 context.draw(tile, in: CGRect(x: 0, y: 0, width: target, height: target)) 253 guard let scaled = context.makeImage() else { return nil } 254 image = scaled 255 } 256 257 let data = NSMutableData() 258 guard let destination = CGImageDestinationCreateWithData( 259 data, 260 UTType.png.identifier as CFString, 261 1, 262 nil 263 ) else { 264 return nil 265 } 266 CGImageDestinationAddImage(destination, image, nil) 267 guard CGImageDestinationFinalize(destination) else { return nil } 268 return data as Data 269 } 270 }