commit 5f4376cb0d7950ba06283be526d26434ee564302
parent 14cdd15ac0e989b1dfdfa148718a619d00212884
Author: Michael Camilleri <[email protected]>
Date: Wed, 29 Jul 2026 08:58:01 +0900
Preserve before-start overlays
Some external puzzles depend on artwork visible from the outset, such as
directional arrows or a chessboard. Crossmate previously fetched only
afterSolve assets, leaving these grids without information needed to
understand their mechanics.
This commit resolves both one-based overlay indices, downloads the two
phases concurrently and stores beforeStart tiles as .before decorations
while keeping OCR exclusive to afterSolve. The standalone conversion
script now follows the same path.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
5 files changed, 167 insertions(+), 67 deletions(-)
diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift
@@ -68,23 +68,29 @@ actor NYTPuzzleFetcher {
expectedLength: httpResponse.expectedContentLength
)
- // Convert NYT JSON to .xd format, baking in the after-solve overlay when
- // the puzzle has one and we can fetch it.
- let overlay = await afterSolveImage(for: data)
- return try NYTToXDConverter.convert(jsonData: data, afterSolveImage: overlay)
+ // Convert NYT JSON to .xd format, baking in both overlay phases when
+ // the puzzle has them and their assets can be fetched.
+ async let beforeStartImage = overlayImage(
+ at: try? NYTToXDConverter.beforeStartImageURL(jsonData: data)
+ )
+ async let afterSolveImage = overlayImage(
+ at: try? NYTToXDConverter.afterSolveImageURL(jsonData: data)
+ )
+ return try await NYTToXDConverter.convert(
+ jsonData: data,
+ beforeStartImage: beforeStartImage,
+ afterSolveImage: afterSolveImage
+ )
}
- /// Fetches the puzzle's after-solve overlay asset, or nil if it has none or
- /// the fetch fails.
+ /// Fetches one puzzle overlay asset, or nil if it has none or the fetch fails.
///
- /// Deliberately swallows every error. The overlay is decoration on a solved
- /// grid; letting a CDN hiccup fail the whole conversion would cost the
- /// player the puzzle to save the confetti. It's also fetched anonymously —
- /// the asset host takes no cookie — so this adds no authenticated traffic.
- private func afterSolveImage(for jsonData: Data) async -> Data? {
- guard let url = try? NYTToXDConverter.afterSolveImageURL(jsonData: jsonData) else {
- return nil
- }
+ /// Deliberately swallows every error. Letting a CDN hiccup fail the whole
+ /// conversion would cost the player the underlying crossword to save its
+ /// artwork. Assets are fetched anonymously, so this adds no authenticated
+ /// traffic.
+ private func overlayImage(at url: URL?) async -> Data? {
+ guard let url else { return nil }
do {
let (bytes, response) = try await URLSession.shared.bytes(from: url)
guard let httpResponse = response as? HTTPURLResponse,
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -21,23 +21,17 @@ enum NYTToXDConverter {
return !(answer.first?.isLetter ?? false)
}
- /// The asset the puzzle wants drawn over the completed grid, if any.
+ /// The asset the puzzle wants drawn over the grid for `key`, if any.
///
- /// `overlays.afterSolve` is a **one-based** index into `assets`, and the
- /// URI must be read from there rather than derived: the host and filename
- /// shape have both changed over the years (a 2021 puzzle points at
- /// `storage.googleapis.com/.../Feb1421.solve.png`, a 2026 one at
- /// `www.nytimes.com/.../Jul2326.daily.solve.png`), so a constructed URL is
- /// wrong for anything but the era it was written in.
- ///
- /// Callers fetch this separately and hand the bytes back to `convert`,
- /// which keeps conversion itself free of I/O.
- static func afterSolveImageURL(jsonData: Data) throws -> URL? {
+ /// Both overlay values are **one-based** indices into `assets`, and the URI
+ /// must be read from there rather than derived: the host and filename shape
+ /// have changed repeatedly over the years.
+ private static func overlayImageURL(jsonData: Data, key: String) throws -> URL? {
guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let bodyArray = root["body"] as? [[String: Any]],
let body = bodyArray.first,
let overlays = body["overlays"] as? [String: Any],
- let index = intValue(overlays["afterSolve"]),
+ let index = intValue(overlays[key]),
let assets = root["assets"] as? [[String: Any]] else {
return nil
}
@@ -50,13 +44,27 @@ enum NYTToXDConverter {
return URL(string: uri)
}
+ /// The asset visible from the start of the puzzle, if any.
+ static func beforeStartImageURL(jsonData: Data) throws -> URL? {
+ try overlayImageURL(jsonData: jsonData, key: "beforeStart")
+ }
+
+ /// The asset revealed over the completed grid, if any.
+ static func afterSolveImageURL(jsonData: Data) throws -> URL? {
+ try overlayImageURL(jsonData: jsonData, key: "afterSolve")
+ }
+
/// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string.
///
- /// `afterSolveImage` is the asset named by `afterSolveImageURL`, when the
- /// caller managed to fetch it. Absent or unusable, the puzzle converts
- /// exactly as before, minus its reveal — the art is never load-bearing for
- /// solving, so failing to fetch it must not fail the conversion.
- static func convert(jsonData: Data, afterSolveImage: Data? = nil) throws -> String {
+ /// The image arguments are the assets named by their corresponding URL
+ /// helpers, when the caller managed to fetch them. Absent or unusable, the
+ /// puzzle still converts without that art; an asset failure must never cost
+ /// the player the underlying crossword.
+ static func convert(
+ jsonData: Data,
+ beforeStartImage: Data? = nil,
+ afterSolveImage: Data? = nil
+ ) throws -> String {
guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
throw ConversionError(message: "Invalid JSON root.")
}
@@ -334,8 +342,18 @@ enum NYTToXDConverter {
shaded: specialCells.shaded,
width: width
)
- for (position, tile) in afterSolveDecorations(
+ for (position, tile) in overlayDecorations(
+ image: beforeStartImage,
+ phase: .before,
+ body: body,
+ width: width,
+ height: height
+ ) {
+ allDecorations[position, default: []].append(tile)
+ }
+ for (position, tile) in overlayDecorations(
image: afterSolveImage,
+ phase: .after,
body: body,
width: width,
height: height
@@ -680,15 +698,15 @@ enum NYTToXDConverter {
return (circled, shaded)
}
- /// Slices the after-solve overlay into per-cell `after`-phase decorations.
+ /// Slices one overlay into per-cell decorations with its original phase.
///
/// Every failure here is silent and total: no image, unreadable geometry,
/// an asset that doesn't match the board, or a payload over budget all
- /// yield no decorations rather than a partial set. A half-revealed grid
- /// reads as a bug, whereas a puzzle with no reveal just looks like a puzzle
- /// without one.
- private static func afterSolveDecorations(
+ /// yield no decorations rather than a partial set. A fragmented overlay
+ /// reads as a bug, whereas missing art still leaves a usable crossword.
+ private static func overlayDecorations(
image: Data?,
+ phase: Puzzle.Decoration.Phase,
body: [String: Any],
width: Int,
height: Int
@@ -711,7 +729,7 @@ enum NYTToXDConverter {
// such as the ENERGY letters hidden in the 2026-07-23 grid. Keeping
// this gate in terms of the raw NYT cells also prevents PUZ imports or
// arbitrary decoration images from ever reaching the recognizer.
- if overlayTargetsOnlyEmptyBlocks(
+ if phase == .after, overlayTargetsOnlyEmptyBlocks(
Set(tiles.keys),
body: body,
width: width,
@@ -722,7 +740,7 @@ enum NYTToXDConverter {
}
}
- return dataDecorations(for: tiles)
+ return dataDecorations(for: tiles, phase: phase)
}
/// Whether every inked overlay tile lands on a NYT block represented by an
@@ -756,7 +774,8 @@ enum NYTToXDConverter {
}
private static func dataDecorations(
- for tiles: [GridPosition: Data]
+ for tiles: [GridPosition: Data],
+ phase: Puzzle.Decoration.Phase
) -> [GridPosition: Puzzle.Decoration] {
tiles.mapValues { png in
Puzzle.Decoration(
@@ -765,7 +784,7 @@ enum NYTToXDConverter {
encoding: "base64",
payload: png.base64EncodedString()
),
- phase: .after
+ phase: phase
)
}
}
diff --git a/Scripts/nyt-to-xd.sh b/Scripts/nyt-to-xd.sh
@@ -76,19 +76,31 @@ if [[ ! -f "$json_path" ]]; then
exit 1
fi
-# Fetch the after-solve overlay, if the puzzle has one, so the converted .xd
-# carries the same baked-in reveal the app would produce. The asset host needs
-# no cookie — `overlays.afterSolve` is a one-based index into `assets`.
-overlay_path=""
-overlay_uri="$(jq -r '
+# Fetch both overlay phases, when present, so the converted .xd carries the
+# same baked-in artwork the app would produce. Asset hosts need no cookie.
+before_start_path=""
+before_start_uri="$(jq -r '
+ (.body[0].overlays.beforeStart // empty) as $i
+ | if ($i | type) == "number" then (.assets[$i - 1].uri // empty) else empty end
+' "$json_path" 2>/dev/null || true)"
+if [[ -n "$before_start_uri" ]]; then
+ before_start_path="${tmp_dir}/before-start.png"
+ if ! curl -sSfL "$before_start_uri" -o "$before_start_path"; then
+ echo "warning: could not fetch overlay ${before_start_uri}; converting without it" >&2
+ before_start_path=""
+ fi
+fi
+
+after_solve_path=""
+after_solve_uri="$(jq -r '
(.body[0].overlays.afterSolve // empty) as $i
| if ($i | type) == "number" then (.assets[$i - 1].uri // empty) else empty end
' "$json_path" 2>/dev/null || true)"
-if [[ -n "$overlay_uri" ]]; then
- overlay_path="${tmp_dir}/overlay.png"
- if ! curl -sSfL "$overlay_uri" -o "$overlay_path"; then
- echo "warning: could not fetch overlay ${overlay_uri}; converting without it" >&2
- overlay_path=""
+if [[ -n "$after_solve_uri" ]]; then
+ after_solve_path="${tmp_dir}/after-solve.png"
+ if ! curl -sSfL "$after_solve_uri" -o "$after_solve_path"; then
+ echo "warning: could not fetch overlay ${after_solve_uri}; converting without it" >&2
+ after_solve_path=""
fi
fi
@@ -102,11 +114,19 @@ guard CommandLine.arguments.count >= 2 else {
}
let url = URL(fileURLWithPath: CommandLine.arguments[1])
let data = try Data(contentsOf: url)
-var overlay: Data? = nil
+var beforeStartImage: Data? = nil
if CommandLine.arguments.count >= 3, !CommandLine.arguments[2].isEmpty {
- overlay = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2]))
+ beforeStartImage = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2]))
+}
+var afterSolveImage: Data? = nil
+if CommandLine.arguments.count >= 4, !CommandLine.arguments[3].isEmpty {
+ afterSolveImage = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[3]))
}
-let xd = try NYTToXDConverter.convert(jsonData: data, afterSolveImage: overlay)
+let xd = try NYTToXDConverter.convert(
+ jsonData: data,
+ beforeStartImage: beforeStartImage,
+ afterSolveImage: afterSolveImage
+)
print(xd)
SWIFT
@@ -114,8 +134,8 @@ binary="${tmp_dir}/nyt-to-xd"
swiftc -O -swift-version 6 "${sources[@]}" "$driver" -o "$binary"
if [[ -n "$output_path" ]]; then
- "$binary" "$json_path" "$overlay_path" > "$output_path"
+ "$binary" "$json_path" "$before_start_path" "$after_solve_path" > "$output_path"
echo "wrote $output_path" >&2
else
- "$binary" "$json_path" "$overlay_path"
+ "$binary" "$json_path" "$before_start_path" "$after_solve_path"
fi
diff --git a/Tests/Unit/NYTOverlaySlicerTests.swift b/Tests/Unit/NYTOverlaySlicerTests.swift
@@ -70,6 +70,42 @@ struct NYTOverlaySlicerTests {
return data as Data
}
+ @Test("Converter preserves both overlay phases in paint order")
+ func converterPreservesBothOverlayPhases() 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)]
+ )
+ 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,
+ afterSolveImage: image
+ )
+ let definitions = xd.split(separator: "\n").filter {
+ $0.contains("data=image/png;base64,")
+ }
+ try #require(definitions.count == 2)
+ #expect(!definitions[0].hasSuffix(" after"))
+ #expect(definitions[1].hasSuffix(" after"))
+ }
+
// MARK: - Geometry
@Test("Geometry is read from the board SVG, not assumed")
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -845,12 +845,23 @@ struct NYTToXDConverterTests {
}
}
- // MARK: - After-solve overlay
+ // MARK: - Overlay assets
- private func overlayJSON(afterSolve: Any?, assetURIs: [String]) throws -> Data {
+ private func overlayJSON(
+ beforeStart: Any? = nil,
+ afterSolve: Any? = nil,
+ assetURIs: [String]
+ ) throws -> Data {
var body: [String: Any] = ["cells": [], "clues": []]
+ var overlays: [String: Any] = [:]
+ if let beforeStart {
+ overlays["beforeStart"] = beforeStart
+ }
if let afterSolve {
- body["overlays"] = ["afterSolve": afterSolve]
+ overlays["afterSolve"] = afterSolve
+ }
+ if !overlays.isEmpty {
+ body["overlays"] = overlays
}
let root: [String: Any] = [
"body": [body],
@@ -859,38 +870,46 @@ struct NYTToXDConverterTests {
return try JSONSerialization.data(withJSONObject: root)
}
- @Test("afterSolve is a one-based index into assets")
- func afterSolveIndexIsOneBased() throws {
+ @Test("Overlay phases are independent one-based indices into assets")
+ func overlayPhaseIndicesAreOneBased() throws {
let data = try overlayJSON(
+ beforeStart: 1,
afterSolve: 2,
assetURIs: ["https://example.test/start.png", "https://example.test/solve.png"]
)
+ #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data)?.absoluteString
+ == "https://example.test/start.png")
#expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data)?.absoluteString
== "https://example.test/solve.png")
}
- @Test("A puzzle with no afterSolve overlay resolves to no URL")
- func noAfterSolveOverlayResolvesToNil() throws {
- let data = try overlayJSON(afterSolve: nil, assetURIs: ["https://example.test/a.png"])
+ @Test("A missing overlay phase resolves to no URL")
+ func missingOverlayPhaseResolvesToNil() throws {
+ let data = try overlayJSON(beforeStart: 1, assetURIs: ["https://example.test/a.png"])
+ #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data)?.absoluteString
+ == "https://example.test/a.png")
#expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil)
}
- @Test("An afterSolve index outside the asset list resolves to no URL")
- func outOfRangeAfterSolveIndexResolvesToNil() throws {
+ @Test("An overlay index outside the asset list resolves to no URL")
+ func outOfRangeOverlayIndexResolvesToNil() throws {
// Off-by-one in either direction: 0 predates the one-based scheme, 3
// runs past the end. Neither may be read as a valid asset.
for index in [0, 3] {
let data = try overlayJSON(
+ beforeStart: index,
afterSolve: index,
assetURIs: ["https://example.test/a.png", "https://example.test/b.png"]
)
+ #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data) == nil)
#expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil)
}
}
@Test("An empty asset URI resolves to no URL")
func emptyAssetURIResolvesToNil() throws {
- let data = try overlayJSON(afterSolve: 1, assetURIs: [""])
+ let data = try overlayJSON(beforeStart: 1, afterSolve: 1, assetURIs: [""])
+ #expect(try NYTToXDConverter.beforeStartImageURL(jsonData: data) == nil)
#expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil)
}