crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }