commit e21888bfc42715d549a51c0e8f5e90304b7b8159
parent 5f4376cb0d7950ba06283be526d26434ee564302
Author: Michael Camilleri <[email protected]>
Date: Wed, 29 Jul 2026 09:23:54 +0900
Render uniform overlay tiles as background colours
Chessboard-style overlays stored every shaded square as a PNG even when
the tile represented only a translucent colour. This commit emits those
tiles as native background decorations, reducing embedded image data
while leaving squares containing pieces or other foreground marks as
images.
The classifier ignores the thin perimeter where the artwork repeats the
grid lines Crossmate already draws, but requires the tile interior to be
a uniform RGBA fill before replacing it.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
3 files changed, 161 insertions(+), 11 deletions(-)
diff --git a/Crossmate/Services/NYTOverlaySlicer.swift b/Crossmate/Services/NYTOverlaySlicer.swift
@@ -113,6 +113,81 @@ enum NYTOverlaySlicer {
return result
}
+ /// Returns an XD hex colour when the interior of a stored tile is a single
+ /// RGBA fill. The thin perimeter is ignored because NYT board artwork can
+ /// repeat the grid lines Crossmate already draws. Variation in the interior
+ /// still rejects the classification, preserving letters, pieces and shapes
+ /// as image data.
+ static func uniformBackgroundHex(imageData: Data) -> String? {
+ guard let source = CGImageSourceCreateWithData(imageData as CFData, nil),
+ let image = CGImageSourceCreateImageAtIndex(source, 0, nil),
+ let pixels = argbPixels(of: image),
+ pixels.count >= 4 else {
+ return nil
+ }
+
+ // Stored tiles are 96 px. An inset of one twelfth clears the 3–5 px
+ // board lines in NYT assets without hiding a meaningful cell symbol.
+ let inset = max(1, min(image.width, image.height) / 12)
+ guard image.width > inset * 2, image.height > inset * 2 else { return nil }
+ let referenceOffset = (
+ (image.height / 2) * image.width + image.width / 2
+ ) * 4
+ let reference = straightRGBA(
+ red: pixels[referenceOffset],
+ green: pixels[referenceOffset + 1],
+ blue: pixels[referenceOffset + 2],
+ alpha: pixels[referenceOffset + 3]
+ )
+ guard reference.alpha > inkAlphaThreshold else { return nil }
+
+ // One level of tolerance absorbs integer rounding when a premultiplied
+ // component is converted back to straight RGBA. It is far too small to
+ // mistake a gradient, symbol or antialiased edge for a flat fill.
+ let tolerance = 1
+ for y in inset..<(image.height - inset) {
+ for x in inset..<(image.width - inset) {
+ let offset = (y * image.width + x) * 4
+ let candidate = straightRGBA(
+ red: pixels[offset],
+ green: pixels[offset + 1],
+ blue: pixels[offset + 2],
+ alpha: pixels[offset + 3]
+ )
+ guard abs(Int(candidate.red) - Int(reference.red)) <= tolerance,
+ abs(Int(candidate.green) - Int(reference.green)) <= tolerance,
+ abs(Int(candidate.blue) - Int(reference.blue)) <= tolerance,
+ abs(Int(candidate.alpha) - Int(reference.alpha)) <= tolerance else {
+ return nil
+ }
+ }
+ }
+
+ let rgb = String(
+ format: "#%02X%02X%02X",
+ reference.red,
+ reference.green,
+ reference.blue
+ )
+ return reference.alpha == 255
+ ? rgb
+ : rgb + String(format: "%02X", reference.alpha)
+ }
+
+ private static func straightRGBA(
+ red: UInt8,
+ green: UInt8,
+ blue: UInt8,
+ alpha: UInt8
+ ) -> (red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8) {
+ guard alpha > 0 else { return (0, 0, 0, 0) }
+ func unpremultiply(_ component: UInt8) -> UInt8 {
+ let value = (Int(component) * 255 + Int(alpha) / 2) / Int(alpha)
+ return UInt8(clamping: value)
+ }
+ return (unpremultiply(red), unpremultiply(green), unpremultiply(blue), alpha)
+ }
+
/// Premultiplied RGBA bytes, four per pixel, row-major from the top left.
private static func argbPixels(of image: CGImage) -> [UInt8]? {
let width = image.width
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -740,7 +740,7 @@ enum NYTToXDConverter {
}
}
- return dataDecorations(for: tiles, phase: phase)
+ return rasterDecorations(for: tiles, phase: phase)
}
/// Whether every inked overlay tile lands on a NYT block represented by an
@@ -773,12 +773,18 @@ enum NYTToXDConverter {
return true
}
- private static func dataDecorations(
+ private static func rasterDecorations(
for tiles: [GridPosition: Data],
phase: Puzzle.Decoration.Phase
) -> [GridPosition: Puzzle.Decoration] {
tiles.mapValues { png in
- Puzzle.Decoration(
+ if let hex = NYTOverlaySlicer.uniformBackgroundHex(imageData: png) {
+ return Puzzle.Decoration(
+ content: .color(layer: .background, light: hex, dark: nil),
+ phase: phase
+ )
+ }
+ return Puzzle.Decoration(
content: .data(
mimeType: "image/png",
encoding: "base64",
diff --git a/Tests/Unit/NYTOverlaySlicerTests.swift b/Tests/Unit/NYTOverlaySlicerTests.swift
@@ -29,7 +29,10 @@ struct NYTOverlaySlicerTests {
cols: Int,
rows: Int,
scale: Int,
- inked: [GridPosition]
+ inked: [GridPosition],
+ inset: CGFloat = 4,
+ color: CGColor = CGColor(red: 1, green: 1, blue: 1, alpha: 1),
+ centerMarkColor: CGColor? = nil
) throws -> Data {
let width = (border * 2 + cell * cols) * scale
let height = (border * 2 + cell * rows) * scale
@@ -43,22 +46,27 @@ struct NYTOverlaySlicerTests {
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
))
context.clear(CGRect(x: 0, y: 0, width: width, height: height))
- context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
+ context.setFillColor(color)
for position in inked {
// `CGContext` puts its origin at the bottom left, so row 0 has to be
// flipped to the top of the image — otherwise the fixture is a
// vertical mirror of the grid it claims to describe.
//
- // Inset so a cell's ink can't bleed into its neighbour and make the
- // test pass for the wrong reason.
+ // The default inset keeps a cell's ink from bleeding into its
+ // neighbour and making the test pass for the wrong reason.
let top = (border + position.row * cell) * scale
let rect = CGRect(
- x: (border + position.col * cell) * scale + 4,
- y: height - top - cell * scale + 4,
- width: cell * scale - 8,
- height: cell * scale - 8
+ x: CGFloat((border + position.col * cell) * scale) + inset,
+ y: CGFloat(height - top - cell * scale) + inset,
+ width: CGFloat(cell * scale) - inset * 2,
+ height: CGFloat(cell * scale) - inset * 2
)
context.fill(rect)
+ if let centerMarkColor {
+ context.setFillColor(centerMarkColor)
+ context.fill(rect.insetBy(dx: rect.width / 3, dy: rect.height / 3))
+ context.setFillColor(color)
+ }
}
let image = try #require(context.makeImage())
let data = NSMutableData()
@@ -106,6 +114,67 @@ struct NYTOverlaySlicerTests {
#expect(definitions[1].hasSuffix(" after"))
}
+ @Test("Converter replaces a uniform translucent tile with a background colour")
+ func converterReplacesUniformTileWithBackground() throws {
+ let board = Self.boardSVG(border: 1, cell: 10, cols: 1)
+ let image = try Self.overlayImage(
+ border: 1,
+ cell: 10,
+ cols: 1,
+ rows: 1,
+ scale: 2,
+ inked: [GridPosition(row: 0, col: 0)],
+ inset: 0,
+ color: CGColor(red: 0, green: 0, blue: 0, alpha: 0.5)
+ )
+ let root: [String: Any] = [
+ "publicationDate": "2025-01-01",
+ "constructors": ["Tester"],
+ "body": [[
+ "dimensions": ["width": 1, "height": 1],
+ "cells": [["answer": "A"]],
+ "clues": [],
+ "board": board
+ ]]
+ ]
+ let json = try JSONSerialization.data(withJSONObject: root)
+
+ let xd = try NYTToXDConverter.convert(jsonData: json, beforeStartImage: image)
+ #expect(xd.contains("bg=#00000080"))
+ #expect(!xd.contains("data=image/png;base64,"))
+ }
+
+ @Test("Converter preserves a foreground mark over a uniform tile")
+ func converterPreservesMarkedUniformTileAsData() throws {
+ let board = Self.boardSVG(border: 1, cell: 10, cols: 1)
+ let image = try Self.overlayImage(
+ border: 1,
+ cell: 10,
+ cols: 1,
+ rows: 1,
+ scale: 2,
+ inked: [GridPosition(row: 0, col: 0)],
+ inset: 0,
+ color: CGColor(red: 0, green: 0, blue: 0, alpha: 0.2),
+ centerMarkColor: CGColor(red: 0, green: 0, blue: 0, alpha: 1)
+ )
+ let root: [String: Any] = [
+ "publicationDate": "2025-01-01",
+ "constructors": ["Tester"],
+ "body": [[
+ "dimensions": ["width": 1, "height": 1],
+ "cells": [["answer": "A"]],
+ "clues": [],
+ "board": board
+ ]]
+ ]
+ let json = try JSONSerialization.data(withJSONObject: root)
+
+ let xd = try NYTToXDConverter.convert(jsonData: json, beforeStartImage: image)
+ #expect(!xd.contains("bg=#"))
+ #expect(xd.contains("data=image/png;base64,"))
+ }
+
// MARK: - Geometry
@Test("Geometry is read from the board SVG, not assumed")