crossmate

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

commit e563f39c0a39115e5e9e41b54eac09da02842bc3
parent 817c68f253277f3e99f49dc98b23b6d0a3b17242
Author: Michael Camilleri <[email protected]>
Date:   Tue, 28 Jul 2026 22:54:25 +0900

Add new Decorations section to XD flavour

Some puzzles hide content that appears only once the grid is complete.
The content can be a separate transparent image rather than puzzle data,
causing the converter to drop it.

This commit adds a `## Decorations` section to the XD parser and writer,
carrying per-cell layers that can target block squares and can be gated
on the puzzle being solved. The section is deliberately not named `##
Design`, the one v4 of the XD format is standardising, since the syntax
here differs.

The new section can bake image data into the puzzle file rather than
fetching it at completion. The new section also supersedes the Specials:
header. Circled and shaded cells keep their letters in the grid instead
of being displaced by `@` and `*` markers and recovered from the clue
answers, and one rebus fill now claims a single placeholder however many
of its occurrences are decorated. ConVer rises to 11.

Co-Authored-By: Claude Opus 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 28++++++++++++++++++++++++++++
ACrossmate/Models/GridPosition.swift | 27+++++++++++++++++++++++++++
MCrossmate/Models/Puzzle.swift | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Models/XD.swift | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
ACrossmate/Models/XDDecorationWriter.swift | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/NYTOverlaySlicer.swift | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/NYTPuzzleFetcher.swift | 39+++++++++++++++++++++++++++++++++++++--
MCrossmate/Services/NYTToXDConverter.swift | 192+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
MCrossmate/Services/PUZToXDConverter.swift | 61++++++++++++++++++++++++++++++++-----------------------------
MCrossmate/Sync/Moves.swift | 26--------------------------
ACrossmate/Views/Puzzle/DecorationImages.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Puzzle/GridView.swift | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MScripts/nyt-to-xd.sh | 79++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
ATests/Unit/DecorationRenderingTests.swift | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/NYTOverlaySlicerTests.swift | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/NYTToXDConverterTests.swift | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
MTests/Unit/PUZToXDConverterTests.swift | 17+++++++++++------
ATests/Unit/XDDecorationTests.swift | 424+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 2084 insertions(+), 196 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; 205894D447BFE2CCEB07575D /* GameStoreRevocationLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8E8D1BC7DF9EBB08F8CABD /* GameStoreRevocationLockTests.swift */; }; 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */; }; + 25141DD1C50C9191B6C94119 /* DecorationImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7557050B17F113BB488AA23 /* DecorationImages.swift */; }; 2571BA6482B3E896A80FF393 /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */; }; 262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; }; 2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */; }; @@ -64,6 +65,7 @@ 351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; }; 35D97436772257DAD3936ECB /* TipStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */; }; 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; + 38439415796652D8B88C53B3 /* XDDecorationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C08795365FABCC11A6C6E28 /* XDDecorationTests.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */; }; @@ -83,6 +85,7 @@ 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; }; 4D9E2C35893E68E47F790994 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */; }; 4E14BB5D6F96D178373ED55A /* PeerChangeLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B427285F8D6BE35025591BFA /* PeerChangeLedger.swift */; }; + 4E1A70D1EC709A0478631DCF /* DecorationRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382F9AAB0B98B4A78E255F15 /* DecorationRenderingTests.swift */; }; 4F1A93404828EDBDBBF86716 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 5082CCB75939E1BC6E9DC03A /* AppDefaultsMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F4D2E1504784E738D2B33A /* AppDefaultsMigratorTests.swift */; }; @@ -121,6 +124,7 @@ 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; 7BE53C851A311A7B6255B45E /* CellAccessibilityDescriberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */; }; + 7CD7CC4681C2085AAC39FA79 /* XDDecorationWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A2D346F414F374A45F92AD /* XDDecorationWriter.swift */; }; 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */; }; 7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */; }; @@ -169,6 +173,7 @@ A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; }; A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; + A4BF96473CD6D937402239C1 /* NYTOverlaySlicer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55219DFC3EEC971E3A8BA6D /* NYTOverlaySlicer.swift */; }; A65F99414F8CF6704567BB07 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C18E9B47668E008BE4CF86 /* Archive.swift */; }; A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; }; A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */; }; @@ -187,6 +192,7 @@ B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */; }; B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; }; B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; }; + BA7FCA7D8713582B2396BE6B /* GridPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9062DC673A2F5AC87FC417AB /* GridPosition.swift */; }; BAB41DBF7D099B1EE46B4ACB /* ClueBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E935CE4384F3B67CC22EEBAC /* ClueBar.swift */; }; BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; }; BD317ECC09C9099AC29B8C5D /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */; }; @@ -236,6 +242,7 @@ E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; E9A65F3548DD062FD36A19E1 /* CellPatterns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F13534332699C80382FE682 /* CellPatterns.swift */; }; EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */; }; + EA9DA088AF131B0E6A874E70 /* NYTOverlaySlicerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7845E863A179DBB0B932C234 /* NYTOverlaySlicerTests.swift */; }; EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; @@ -314,6 +321,7 @@ 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; }; 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameArchiver.swift; sourceTree = "<group>"; }; + 1C08795365FABCC11A6C6E28 /* XDDecorationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDDecorationTests.swift; sourceTree = "<group>"; }; 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 23A7390848B1943C80142934 /* PuzzleGridStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGridStats.swift; sourceTree = "<group>"; }; @@ -340,6 +348,7 @@ 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadCipher.swift; sourceTree = "<group>"; }; 3413F8755236FC0578AF8109 /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSessionTests.swift; sourceTree = "<group>"; }; + 382F9AAB0B98B4A78E255F15 /* DecorationRenderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationRenderingTests.swift; sourceTree = "<group>"; }; 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayLoader.swift; sourceTree = "<group>"; }; 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; }; @@ -398,6 +407,7 @@ 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74C8886A66F0877858A67D62 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; + 7845E863A179DBB0B932C234 /* NYTOverlaySlicerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTOverlaySlicerTests.swift; sourceTree = "<group>"; }; 78919F44C3035C48410FC894 /* GamePushCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePushCredentials.swift; sourceTree = "<group>"; }; 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesJournalTests.swift; sourceTree = "<group>"; }; 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; }; @@ -422,6 +432,7 @@ 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; }; 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipStore.swift; sourceTree = "<group>"; }; 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; }; + 9062DC673A2F5AC87FC417AB /* GridPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridPosition.swift; sourceTree = "<group>"; }; 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectoryTests.swift; sourceTree = "<group>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; @@ -444,6 +455,7 @@ A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementMessageAuthenticatorTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleScoreboard.swift; sourceTree = "<group>"; }; + A55219DFC3EEC971E3A8BA6D /* NYTOverlaySlicer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTOverlaySlicer.swift; sourceTree = "<group>"; }; A61768D423564A9080414A38 /* NoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeView.swift; sourceTree = "<group>"; }; A8C18E9B47668E008BE4CF86 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = "<group>"; }; A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; }; @@ -464,6 +476,7 @@ B3D873ABDF871E14794A2845 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; }; B427285F8D6BE35025591BFA /* PeerChangeLedger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChangeLedger.swift; sourceTree = "<group>"; }; B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B7557050B17F113BB488AA23 /* DecorationImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationImages.swift; sourceTree = "<group>"; }; B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; }; B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; @@ -479,6 +492,7 @@ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScrubberTests.swift; sourceTree = "<group>"; }; C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayload.swift; sourceTree = "<group>"; }; + C3A2D346F414F374A45F92AD /* XDDecorationWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDDecorationWriter.swift; sourceTree = "<group>"; }; C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; }; C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLogTests.swift; sourceTree = "<group>"; }; C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipStoreTests.swift; sourceTree = "<group>"; }; @@ -620,6 +634,7 @@ B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */, 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */, 5E6BB07A54A3C3AF6F210F49 /* CloudJoinFailureTests.swift */, + 382F9AAB0B98B4A78E255F15 /* DecorationRenderingTests.swift */, 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, @@ -643,6 +658,7 @@ FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, + 7845E863A179DBB0B932C234 /* NYTOverlaySlicerTests.swift */, A0CCED43B365363662C45A30 /* NYTPuzzleFetcherTests.swift */, B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, @@ -678,6 +694,7 @@ C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */, C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, + 1C08795365FABCC11A6C6E28 /* XDDecorationTests.swift */, 9F6DB4116933BB66274AC6E8 /* XDLimitsTests.swift */, F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, @@ -697,6 +714,7 @@ 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */, B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */, + 9062DC673A2F5AC87FC417AB /* GridPosition.swift */, E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */, FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */, DB55FC337CF72C650373210A /* PlayerColor.swift */, @@ -713,6 +731,7 @@ AFFB5B2EFBB62B7021AC2FC2 /* TimeLog.swift */, 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */, B9031A1574C21866940F6A2C /* XD.swift */, + C3A2D346F414F374A45F92AD /* XDDecorationWriter.swift */, EAC61E2582D94B1E6EC67136 /* XDFileType.swift */, EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */, F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */, @@ -786,6 +805,7 @@ 9F13534332699C80382FE682 /* CellPatterns.swift */, E935CE4384F3B67CC22EEBAC /* ClueBar.swift */, 7B8B65482CA1739A3863A99E /* ClueList.swift */, + B7557050B17F113BB488AA23 /* DecorationImages.swift */, FF4CA70D10760D6770907606 /* GridAccessibility.swift */, ED48AD9C3A7A113D101BBD21 /* GridView.swift */, 6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */, @@ -921,6 +941,7 @@ 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, + A55219DFC3EEC971E3A8BA6D /* NYTOverlaySlicer.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */, BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, @@ -1082,6 +1103,7 @@ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 7BE53C851A311A7B6255B45E /* CellAccessibilityDescriberTests.swift in Sources */, 8171E5B23FEC5FB63029A640 /* CloudJoinFailureTests.swift in Sources */, + 4E1A70D1EC709A0478631DCF /* DecorationRenderingTests.swift in Sources */, 8C37D748EBFD75DACCB992C3 /* DriveMonitorTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */, @@ -1112,6 +1134,7 @@ F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */, C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, + EA9DA088AF131B0E6A874E70 /* NYTOverlaySlicerTests.swift in Sources */, DCAA1C4A4E4943675EC6632A /* NYTPuzzleFetcherTests.swift in Sources */, 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, @@ -1160,6 +1183,7 @@ F15591B48E4155CB19C1F084 /* TimeLogTests.swift in Sources */, 9AD5700398B1C1F29A3A75F6 /* TipStoreTests.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, + 38439415796652D8B88C53B3 /* XDDecorationTests.swift in Sources */, 47B4FB563BA4A8A54E2C26E0 /* XDLimitsTests.swift in Sources */, 786813F3418C32EFBF296220 /* XDMarkupTests.swift in Sources */, 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */, @@ -1198,6 +1222,7 @@ C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, C472EF02D8C7B0AC1D2284B8 /* CustomButtons.swift in Sources */, CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */, + 25141DD1C50C9191B6C94119 /* DecorationImages.swift in Sources */, F1854B64C63447B7C7232097 /* DiagnosticsReport.swift in Sources */, 0F2992C16A3A658DEA0F707E /* DiagnosticsView.swift in Sources */, 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */, @@ -1229,6 +1254,7 @@ 774C5B5FC06366A0F5CD2376 /* GameSyncVersion.swift in Sources */, 1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */, 83CF8376EBFD7271931AC1B7 /* GridAccessibility.swift in Sources */, + BA7FCA7D8713582B2396BE6B /* GridPosition.swift in Sources */, 4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */, ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */, 1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */, @@ -1255,6 +1281,7 @@ 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, C843CADAA263CED503528A4E /* NYTBrowseView.swift in Sources */, 6D2AF361587E43D807BA212F /* NYTLoginView.swift in Sources */, + A4BF96473CD6D937402239C1 /* NYTOverlaySlicer.swift in Sources */, 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */, 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */, B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, @@ -1312,6 +1339,7 @@ 35D97436772257DAD3936ECB /* TipStore.swift in Sources */, 3C54B672A9FCA98C0A304470 /* TipsArchive.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, + 7CD7CC4681C2085AAC39FA79 /* XDDecorationWriter.swift in Sources */, 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */, 14749A042380925B7CA902F2 /* XDMarkup.swift in Sources */, ); diff --git a/Crossmate/Models/GridPosition.swift b/Crossmate/Models/GridPosition.swift @@ -0,0 +1,27 @@ +import Foundation + +/// A row/column coordinate inside a puzzle grid. Also used as the dictionary +/// key for `GridState`, `MovesValue.cells` and `Puzzle.decorations`. +struct GridPosition: Hashable, Sendable, Codable { + let row: Int + let col: Int +} + +extension GridPosition { + /// `true` when both coordinates survive the trapping `Int16` conversions + /// every Core Data cell/journal sink performs. The remote codecs drop + /// entries that fail this, so a forged record can't persist a value that + /// crashes cache replay on every subsequent fetch. + var isInt16Representable: Bool { + row >= 0 && col >= 0 && row <= Int(Int16.max) && col <= Int(Int16.max) + } + + /// `isInt16Representable` plus grid-shape bounds, for sinks that know the + /// game's dimensions. Zero dimensions (a row that predates the recorded + /// grid size) skip the shape check rather than rejecting everything. + func isPersistable(gridWidth: Int16, gridHeight: Int16) -> Bool { + guard isInt16Representable else { return false } + guard gridWidth > 0, gridHeight > 0 else { return true } + return row < Int(gridHeight) && col < Int(gridWidth) + } +} diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -17,6 +17,42 @@ struct Puzzle: Sendable { case shaded } + /// One layer drawn on a cell, from a single `<char>. <kind>=<value>` line in + /// an `.xd` `## Decorations` section. A cell stacks as many layers as its + /// design character has definition lines; repeating the character is how the + /// format composes, which keeps each line independently meaningful — an + /// unreadable one can be dropped without losing the rest of the cell. + struct Decoration: Sendable, Hashable { + let content: Content + let phase: Phase + + /// When a layer becomes visible. `before` — the default, and what an + /// omitted keyword means — is visible from the start; `after` is + /// revealed only once the puzzle is solved. + enum Phase: Sendable, Hashable { + case before + case after + } + + /// Which layer of the cell a colour applies to. + enum ColorLayer: Sendable, Hashable { + case background + case foreground + } + + enum Content: Sendable, Hashable { + case mark(Special) + /// A `bg=` / `fg=` layer. The appearance variants live in the value + /// (`bg=<light>;<dark>`) rather than in the kind, so one line fully + /// describes a cell's colour and there's no way to write a light + /// value on one line and a dark one on another that contradicts it. + /// `dark` is nil for the single-value form, which applies to both. + case color(layer: ColorLayer, light: String, dark: String?) + case text(String) + case data(mimeType: String, encoding: String, payload: String) + } + } + let title: String let publisher: String? let author: String? @@ -53,6 +89,12 @@ struct Puzzle: Sendable { /// Built once at init. let cellGroups: [GridPosition: Int] + /// Per-cell decoration layers, in paint order. Unlike `Cell.special` these are + /// carried verbatim rather than folded into the cell, because a cell can + /// stack several, they can target block squares, and they can be gated on + /// the puzzle being solved. + let decorations: [GridPosition: [Decoration]] + struct ClueRef: Hashable, Sendable { let number: Int let direction: Direction @@ -119,6 +161,24 @@ struct Puzzle: Sendable { self.width = xd.width self.height = xd.height + self.decorations = xd.decorations + + // A `mark=circle` / `mark=shaded` layer in the `before` phase is exactly + // what the legacy `Specials:` header expressed, so it feeds the same + // `Cell.special` the renderer already reads and no drawing code has to + // learn about decorations to keep circles and shading working. A legacy + // header still wins where both are present, which can only happen while + // a source is mid-migration. + var markedSpecials: [GridPosition: Special] = [:] + for (position, layers) in xd.decorations { + for layer in layers where layer.phase == .before { + if case .mark(let special) = layer.content { + markedSpecials[position] = special + break + } + } + } + // Clue numbering is computed from grid topology rather than carried // in the source, since .xd has no per-cell number field. A cell is // numbered if it begins an across or down word — i.e. its preceding @@ -148,7 +208,8 @@ struct Puzzle: Sendable { number = nil } let normalizedAccepted = Set(acceptedSolutions.map { Cell.normalizedAnswer($0) }) - rowCells.append(Cell(row: r, col: c, isBlock: false, special: special, number: number, solution: solution, acceptedSolutions: normalizedAccepted)) + let effectiveSpecial = special ?? markedSpecials[GridPosition(row: r, col: c)] + rowCells.append(Cell(row: r, col: c, isBlock: false, special: effectiveSpecial, number: number, solution: solution, acceptedSolutions: normalizedAccepted)) } } cells.append(rowCells) diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -10,14 +10,14 @@ struct XD: Sendable { /// owned NYT game should be re-fetched and re-converted. Only bumped when a /// converter changes; puzzles generated in-house (Crossmake, bundled) carry /// no converter version at all. - static let currentConverterVersion = 10 + static let currentConverterVersion = 11 /// Version of the XD→Puzzle/Core Data processing (`XD.parse`, `Puzzle(xd:)`, /// and the cached summary fields). Held on the game entity, never /// serialized, and compared by `GameStore.preparePuzzleForLoad` to decide /// whether a game must be reparsed and its cache refreshed. Bumped only when /// that processing changes. - static let currentParserVersion = 10 + static let currentParserVersion = 11 /// Upper bound on a `.xd` source handed to `parse`. The largest puzzle we /// ship is ~3 KB, so this is ~80× real content — no genuine puzzle is ever @@ -59,6 +59,13 @@ struct XD: Sendable { /// with `unknownGridCharacter`. static let maxRebusValueLength = 64 + /// Upper bound on definition lines in the `## Decorations` section. Parsing is + /// linear in the source, which `maxSourceBytes` already bounds, so this + /// exists only to cap the per-character dictionary a malformed source can + /// grow. A design grid can reference at most `maxGridDimension²` distinct + /// characters, and real decorations use a few dozen. + static let maxDecorationDefinitions = 4_096 + /// Single-character grid placeholders for multi-letter (rebus) fills, used /// by every converter that *writes* `.xd`. The grid is one character per /// cell, so a cell whose fill is longer than one letter shows one of these @@ -93,6 +100,10 @@ struct XD: Sendable { let acrossClues: [Clue] let downClues: [Clue] let relatives: [[ClueRef]] + /// Per-cell decoration layers from the `## Decorations` section, in the order + /// their definition lines appeared — which is also the order they paint. + /// Empty for the (overwhelmingly common) source that has no such section. + let decorations: [GridPosition: [Decoration]] /// A single clue identified by its number and direction. Used to describe /// groups of mutually-related clues (a theme's revealer plus the answers @@ -111,6 +122,10 @@ struct XD: Sendable { case open(solution: String?, acceptedSolutions: Set<String>, special: Puzzle.Special?) } + /// Decoration layers are format-independent, so they live on `Puzzle` + /// alongside `Special`; this alias keeps the parser's references short. + typealias Decoration = Puzzle.Decoration + struct Clue: Sendable, Equatable { let number: Int let text: String @@ -170,6 +185,10 @@ struct XD: Sendable { case clueAnswerMismatch(String) case ambiguousClueAnswer(String) case missingInferredSolution(row: Int, col: Int) + case decorationGridMismatch(expected: String, found: String) + case unknownDecorationCharacter(Character) + case malformedDecoration(String) + case tooManyDecorations var description: String { switch self { @@ -199,6 +218,14 @@ struct XD: Sendable { return ".xd clue answer cannot be unambiguously projected onto grid: \(clue)" case .missingInferredSolution(let row, let col): return ".xd grid cell at row \(row + 1), column \(col + 1) has no inferred solution" + case .decorationGridMismatch(let expected, let found): + return ".xd decoration design grid is \(found), but the puzzle grid is \(expected)" + case .unknownDecorationCharacter(let ch): + return "unknown .xd decoration design character: \(ch)" + case .malformedDecoration(let line): + return "malformed .xd decoration definition: \(line)" + case .tooManyDecorations: + return ".xd has more decoration definitions than the maximum supported" } } @@ -233,6 +260,8 @@ struct XD: Sendable { return "has an answer Crossmate couldn't place in its grid" case .missingInferredSolution: return "has a square Crossmate couldn't solve" + case .decorationGridMismatch, .unknownDecorationCharacter, .malformedDecoration, .tooManyDecorations: + return "has decoration Crossmate couldn't read" } } } @@ -241,7 +270,12 @@ struct XD: Sendable { guard source.utf8.count <= maxSourceBytes else { throw ParseError.sourceTooLarge } - let sections = splitIntoSections(source) + let allSections = splitIntoSections(source) + // Named optional sections are lifted out first so they can appear in any + // order (or not at all) without disturbing the positional three that + // every `.xd` source has carried since v1. + let decorationsSection = allSections.first { isNamed($0, "Decorations") } + let sections = allSections.filter { !isNamed($0, "Decorations") }.map(\.lines) guard sections.count >= 2 else { throw ParseError.missingGrid } guard sections.count >= 3 else { throw ParseError.missingClues } @@ -271,6 +305,9 @@ struct XD: Sendable { let solvedCells = try applyClueAnswers(cells: rawCells, across: across, down: down, numbering: numbering) let cells = applyAcceptedAnswers(cells: solvedCells, across: across, down: down, numbering: numbering) try validateASCIIEntryOptions(cells: cells) + // Parsed last: the design grid is validated against the puzzle's own + // dimensions, which aren't known until the grid section is parsed. + let decorations = try parseDecorations(decorationsSection?.lines, width: width, height: height) return XD( title: metadata.first("Title"), @@ -284,10 +321,15 @@ struct XD: Sendable { cells: cells, acrossClues: across, downClues: down, - relatives: relatives + relatives: relatives, + decorations: decorations ) } + private static func isNamed(_ section: Section, _ name: String) -> Bool { + section.name?.caseInsensitiveCompare(name) == .orderedSame + } + private static func validateASCIIEntryOptions(cells: [[Cell]]) throws { for row in cells { for cell in row { @@ -318,19 +360,32 @@ struct XD: Sendable { // MARK: - Sections + /// One top-level section of the source, with the `## Name` label that + /// introduced it (if any). The first three sections are still identified by + /// order — metadata, grid, clues — because the vast majority of `.xd` + /// sources carry no labels at all. Optional sections that arrive later + /// (`## Decorations`) are found by name instead, so their position can't matter + /// and an unrecognised label is simply skipped rather than shifting the + /// positional three. + private struct Section { + let name: String? + let lines: [String] + } + /// Splits the source into top-level sections. Per the .xd spec, sections /// are delimited either by runs of two or more blank lines, or by /// `## SectionName` header lines. We accept both: blank-line runs end the /// current section, and a `##` line also ends the current section (the - /// header line itself is consumed and discarded — section identity comes - /// from implicit order). - private static func splitIntoSections(_ source: String) -> [[String]] { + /// header line is consumed, and its label attaches to the section that + /// *follows* it). + private static func splitIntoSections(_ source: String) -> [Section] { let lines = source .split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) - var sections: [[String]] = [] + var sections: [Section] = [] var current: [String] = [] + var currentName: String? = nil var blankRun = 0 func flush() { @@ -341,17 +396,24 @@ struct XD: Sendable { current.removeFirst() } if !current.isEmpty { - sections.append(current) + sections.append(Section(name: currentName, lines: current)) } current = [] + currentName = nil } for rawLine in lines { let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) let trimmed = line.trimmingCharacters(in: .whitespaces) + // The test is deliberately `"## "` (with the trailing space) or a + // bare `"##"`, never `hasPrefix("##")`: `#` is the block character, + // so an all-block grid row such as `###` would otherwise be + // mistaken for a section header and split the grid in two. if trimmed.hasPrefix("## ") || trimmed == "##" { flush() + let label = trimmed.dropFirst(2).trimmingCharacters(in: .whitespaces) + currentName = label.isEmpty ? nil : label blankRun = 0 continue } @@ -391,7 +453,7 @@ struct XD: Sendable { /// sections that the parser rejects. static func metadataValue(_ key: String, in source: String) -> String? { guard let header = splitIntoSections(source).first else { return nil } - return parseMetadata(header).first(key) + return parseMetadata(header.lines).first(key) } private static func parseMetadata(_ lines: [String]) -> Metadata { @@ -655,6 +717,165 @@ struct XD: Sendable { throw ParseError.unknownGridCharacter(ch) } + // MARK: - Decorations + + /// Parses the optional `## Decorations` section: a design grid of the same + /// dimensions as the puzzle, plus `<char>. <kind>=<value> [before|after]` + /// definition lines. + /// + /// The two line kinds are told apart by whitespace — a definition line + /// always has some (at minimum the space after `<char>.`) and a design-grid + /// line never does — so the section reads correctly whichever order they + /// appear in, and the blank line between them is decorative. + private static func parseDecorations( + _ lines: [String]?, + width: Int, + height: Int + ) throws -> [GridPosition: [Decoration]] { + guard let lines, !lines.isEmpty else { return [:] } + + var gridLines: [String] = [] + var definitionLines: [String] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + if trimmed.contains(where: \.isWhitespace) { + definitionLines.append(trimmed) + } else { + gridLines.append(trimmed) + } + } + + guard definitionLines.count <= maxDecorationDefinitions else { + throw ParseError.tooManyDecorations + } + + // A design grid that doesn't match the puzzle can't be interpreted at + // all — unlike a bad definition line, which costs only its own layer — + // so a mismatch fails the parse instead of being cropped or padded into + // a shape that would silently decorate the wrong squares. + guard gridLines.count == height, gridLines.allSatisfy({ $0.count == width }) else { + let found = gridLines.isEmpty + ? "empty" + : "\(gridLines.map(\.count).max() ?? 0)×\(gridLines.count)" + throw ParseError.decorationGridMismatch(expected: "\(width)×\(height)", found: found) + } + + var definedCharacters: Set<Character> = [] + var decorationsByCharacter: [Character: [Decoration]] = [:] + for line in definitionLines { + let (key, decoration) = try parseDecorationDefinition(line) + // The character counts as defined even when its kind isn't one we + // understand, so a source written against a newer spec revision + // still resolves its design grid instead of failing outright. + definedCharacters.insert(key) + if let decoration { + decorationsByCharacter[key, default: []].append(decoration) + } + } + + var result: [GridPosition: [Decoration]] = [:] + for (row, line) in gridLines.enumerated() { + for (col, ch) in line.enumerated() { + if ch == "." { continue } + guard definedCharacters.contains(ch) else { + throw ParseError.unknownDecorationCharacter(ch) + } + guard let layers = decorationsByCharacter[ch], !layers.isEmpty else { continue } + result[GridPosition(row: row, col: col)] = layers + } + } + return result + } + + /// Parses one `<char>. <kind>=<value> [before|after]` line into the design + /// character it defines plus the layer it declares. Returns a `nil` layer + /// for a structurally sound line whose kind we don't recognise, so a single + /// unknown kind costs one layer rather than the whole section. + private static func parseDecorationDefinition( + _ line: String + ) throws -> (key: Character, decoration: Decoration?) { + guard let key = line.first, + let dot = line.firstIndex(of: "."), + line.distance(from: line.startIndex, to: dot) == 1 else { + throw ParseError.malformedDecoration(line) + } + var body = line[line.index(after: dot)...].trimmingCharacters(in: .whitespaces) + guard !body.isEmpty else { throw ParseError.malformedDecoration(line) } + + // The phase keyword is stripped off the end rather than tokenised out of + // a whitespace split, so a value can itself contain spaces + // (`text=NEW YORK`) without needing an escape. + var phase = Decoration.Phase.before + for (keyword, candidate) in [("after", Decoration.Phase.after), ("before", Decoration.Phase.before)] + where body.hasSuffix(" " + keyword) { + phase = candidate + body = String(body.dropLast(keyword.count + 1)).trimmingCharacters(in: .whitespaces) + break + } + + guard let equals = body.firstIndex(of: "=") else { + throw ParseError.malformedDecoration(line) + } + let kind = String(body[..<equals]).lowercased() + let value = String(body[body.index(after: equals)...]) + guard !value.isEmpty else { throw ParseError.malformedDecoration(line) } + + guard let content = decorationContent(kind: kind, value: value) else { + return (key, nil) + } + return (key, Decoration(content: content, phase: phase)) + } + + private static func decorationContent(kind: String, value: String) -> Decoration.Content? { + switch kind { + case "mark": + switch value.lowercased() { + case "circle", "circled": return .mark(.circled) + case "shaded": return .mark(.shaded) + default: return nil + } + case "bg": return decorationColor(layer: .background, value: value) + case "fg": return decorationColor(layer: .foreground, value: value) + case "text": return .text(value) + case "data": return decorationData(value) + default: return nil + } + } + + /// Splits a `bg` / `fg` value into its appearance variants: a lone colour + /// applies to both, `<light>;<dark>` gives one for each. More than two parts + /// (or an empty one) is malformed and yields no layer rather than a guess at + /// which variant was meant. + private static func decorationColor( + layer: Decoration.ColorLayer, + value: String + ) -> Decoration.Content? { + let parts = value + .split(separator: ";", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard (1...2).contains(parts.count), parts.allSatisfy({ !$0.isEmpty }) else { + return nil + } + return .color(layer: layer, light: parts[0], dark: parts.count == 2 ? parts[1] : nil) + } + + /// Splits a `data` value's `<mime-type>[;<encoding>],<payload>` descriptor. + /// Base64's alphabet contains no comma, so splitting on the first one is + /// unambiguous however large the payload. An omitted encoding defaults to + /// base64, matching the data-URI convention the syntax borrows from. + private static func decorationData(_ value: String) -> Decoration.Content? { + guard let comma = value.firstIndex(of: ",") else { return nil } + let descriptor = value[..<comma] + let payload = String(value[value.index(after: comma)...]) + guard !payload.isEmpty else { return nil } + let parts = descriptor.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false) + let mimeType = String(parts[0]) + guard !mimeType.isEmpty else { return nil } + let encoding = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : "base64" + return .data(mimeType: mimeType, encoding: encoding, payload: payload) + } + // MARK: - Clues private static func parseClues( diff --git a/Crossmate/Models/XDDecorationWriter.swift b/Crossmate/Models/XDDecorationWriter.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Renders the `.xd` `## Decorations` section. Shared by every converter that +/// writes `.xd` so the key allocation and definition-line grammar live in one +/// place, symmetric with `XD.parseDecorations` — the only reader. +enum XDDecorationWriter { + enum WriteError: LocalizedError { + case tooManyDistinctDecorations(Int) + + var errorDescription: String? { + switch self { + case .tooManyDistinctDecorations(let count): + return "Too many distinct decoration stacks (\(count)); ran out of design-grid keys." + } + } + } + + /// Keys preferred for the two decorations that predate the section, so an + /// ordinary circles-and-shading puzzle keeps a design grid a person can read + /// at a glance rather than one keyed by arbitrary digits. + private static let mnemonicKeys: [Puzzle.Decoration: Character] = [ + Puzzle.Decoration(content: .mark(.circled), phase: .before): "O", + Puzzle.Decoration(content: .mark(.shaded), phase: .before): "S", + ] + + /// Every character the design grid may use. Digits and letters come first + /// because they read best; the symbol tail only matters for grid art, which + /// can run to dozens of distinct per-cell images. + /// + /// Three exclusions: `.` means "no decoration", whitespace would make a grid + /// line look like a definition line (that's how the two are told apart), and + /// `#` is dropped so an all-`#` design row can't be mistaken for a `##` + /// section header the way an all-block *puzzle* row nearly is. + private static let poolKeys: [Character] = { + let reserved: Set<Character> = [".", "#", "O", "S"] + let symbols = (UInt8(33)...UInt8(126)) + .map { Character(UnicodeScalar($0)) } + .filter { !$0.isLetter && !$0.isNumber && !reserved.contains($0) } + // `0` is left out for the same reason `XD.rebusPlaceholders` omits it: + // it reads as `O` in a dense grid of single characters. + let letters = Array("123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + .filter { !reserved.contains($0) } + return letters + symbols + }() + + /// Renders the section, or `nil` when there is nothing to draw. The returned + /// string includes its own `## Decorations` header, so a caller appends it to + /// the section list exactly like the metadata, grid and clue sections. + static func section( + decorations: [GridPosition: [Puzzle.Decoration]], + width: Int, + height: Int + ) throws -> String? { + let populated = decorations.filter { !$0.value.isEmpty } + guard !populated.isEmpty else { return nil } + + // Discover distinct stacks in row-major order so key assignment — and + // therefore the emitted text — is identical for identical input. Without + // this the output would vary run to run with dictionary ordering, making + // converter output undiffable and round-trip tests unassertable. + var order: [[Puzzle.Decoration]] = [] + var seen: Set<[Puzzle.Decoration]> = [] + for row in 0..<height { + for col in 0..<width { + guard let stack = populated[GridPosition(row: row, col: col)] else { continue } + if seen.insert(stack).inserted { + order.append(stack) + } + } + } + + // Mnemonics are claimed first so a stack entitled to one is never beaten + // to it by an earlier stack drawing from the pool. + var used: Set<Character> = [] + var keys: [[Puzzle.Decoration]: Character] = [:] + for stack in order where stack.count == 1 { + guard let key = mnemonicKeys[stack[0]], !used.contains(key) else { continue } + keys[stack] = key + used.insert(key) + } + var pool = poolKeys.makeIterator() + for stack in order where keys[stack] == nil { + guard let key = pool.next() else { + throw WriteError.tooManyDistinctDecorations(order.count) + } + keys[stack] = key + used.insert(key) + } + + var gridLines: [String] = [] + gridLines.reserveCapacity(height) + for row in 0..<height { + var line = "" + for col in 0..<width { + if let stack = populated[GridPosition(row: row, col: col)], let key = keys[stack] { + line.append(key) + } else { + line.append(".") + } + } + gridLines.append(line) + } + + var definitionLines: [String] = [] + for stack in order { + guard let key = keys[stack] else { continue } + for decoration in stack { + definitionLines.append(line(key: key, decoration: decoration)) + } + } + + return "## Decorations\n\n" + + gridLines.joined(separator: "\n") + + "\n\n" + + definitionLines.joined(separator: "\n") + } + + private static func line(key: Character, decoration: Puzzle.Decoration) -> String { + var out = "\(key). " + pair(decoration.content) + // `before` is the default, so it's left off — the common case stays the + // short one, and a reader only sees a phase where it matters. + if decoration.phase == .after { out += " after" } + return out + } + + private static func pair(_ content: Puzzle.Decoration.Content) -> String { + switch content { + case .mark(let special): + return "mark=" + (special == .circled ? "circle" : "shaded") + case .color(let layer, let light, let dark): + let kind = layer == .background ? "bg" : "fg" + guard let dark else { return "\(kind)=\(light)" } + return "\(kind)=\(light);\(dark)" + case .text(let value): + return "text=\(value)" + case .data(let mimeType, let encoding, let payload): + return "data=\(mimeType);\(encoding),\(payload)" + } + } +} diff --git a/Crossmate/Services/NYTOverlaySlicer.swift b/Crossmate/Services/NYTOverlaySlicer.swift @@ -0,0 +1,195 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Cuts a NYT overlay asset (`overlays.afterSolve` / `overlays.beforeStart`) +/// into per-cell PNG tiles that can ride in an `.xd` `## Decorations` section. +/// +/// The overlay is a single transparent image the size of the whole board, so +/// the only way to attach it to cells is to slice it on the grid's own +/// geometry. That geometry is read from the puzzle's `board` SVG rather than +/// assumed: the NYT scales cell size to grid size — 33 units for a 15×15 +/// (viewBox 501), 23 for a 21×21 (viewBox 489) — so a hardcoded cell size +/// silently misaligns every tile on a Sunday. +enum NYTOverlaySlicer { + /// Edge length a tile is stored at. A grid cell renders at roughly 72px on + /// an iPhone and 92px on an iPad, so 96 covers both without paying for the + /// ~150px the source assets ship at. + static let tileSize = 96 + + /// Alpha above which a pixel counts as ink. Comfortably above the fringe + /// left by antialiasing so a tile isn't kept for a neighbour's halo, and + /// far below anything a designer would intend to be visible. + static let inkAlphaThreshold: UInt8 = 32 + + /// Ceiling on the combined base64 payload of one overlay. `XD.maxSourceBytes` + /// is 256 KB and has to hold the grid, clues and both overlay phases, so a + /// single phase is held to well under half. An overlay past this is dropped + /// whole rather than truncated: a puzzle missing its art still plays, but a + /// puzzle whose source blows the parse cap doesn't open at all. + static let maxEncodedBytes = 96 * 1024 + + /// Where the grid sits inside the overlay's coordinate space, in SVG units, + /// plus the scale factor from those units to image pixels. + struct Geometry: Equatable { + let border: Double + let cellSize: Double + let viewBoxWidth: Double + } + + /// Reads `viewBox` and the first cell rect out of a puzzle's `board` SVG. + /// The first `<path d="M{border} {border}h{cell}v{cell}…">` is cell (0,0), + /// so it yields both the margin and the cell pitch directly. + static func geometry(boardSVG: String) -> Geometry? { + guard let viewBox = boardSVG.firstMatch( + of: /viewBox="[\d.]+ [\d.]+ ([\d.]+) [\d.]+"/ + ), let viewBoxWidth = Double(viewBox.1) else { + return nil + } + guard let cell = boardSVG.firstMatch( + of: /d="M([\d.]+) [\d.]+h([\d.]+)v/ + ), let border = Double(cell.1), let cellSize = Double(cell.2) else { + return nil + } + guard viewBoxWidth > 0, cellSize > 0, border >= 0 else { return nil } + return Geometry(border: border, cellSize: cellSize, viewBoxWidth: viewBoxWidth) + } + + /// Slices `imageData` into one PNG per cell that carries ink. Returns `nil` + /// when the image can't be read, when the derived geometry doesn't fit it, + /// or when the encoded result would exceed `maxEncodedBytes`. + static func tiles( + imageData: Data, + geometry: Geometry, + width: Int, + height: Int + ) -> [GridPosition: Data]? { + guard width > 0, height > 0 else { return nil } + guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + return nil + } + + let imageWidth = image.width + let imageHeight = image.height + let scale = Double(imageWidth) / geometry.viewBoxWidth + let cell = geometry.cellSize * scale + let border = geometry.border * scale + // The grid has to actually fit the image it was derived from; if it + // doesn't, the SVG and the asset disagree and every tile would be cut + // from the wrong place. + guard cell >= 1, + border + cell * Double(width) <= Double(imageWidth) + 1, + border + cell * Double(height) <= Double(imageHeight) + 1 else { + return nil + } + + guard let pixels = argbPixels(of: image) else { return nil } + + var result: [GridPosition: Data] = [:] + var encodedTotal = 0 + for row in 0..<height { + for col in 0..<width { + let rect = CGRect( + x: border + Double(col) * cell, + y: border + Double(row) * cell, + width: cell, + height: cell + ).integral + guard hasInk(pixels, imageWidth: imageWidth, imageHeight: imageHeight, in: rect), + let tile = image.cropping(to: rect), + let png = pngData(scaling: tile, to: tileSize) else { + continue + } + // base64 is 4 bytes out for every 3 in; check as we go so a + // pathological overlay is abandoned early rather than after + // encoding hundreds of tiles. + encodedTotal += (png.count + 2) / 3 * 4 + guard encodedTotal <= maxEncodedBytes else { return nil } + result[GridPosition(row: row, col: col)] = png + } + } + return result + } + + /// Premultiplied RGBA bytes, four per pixel, row-major from the top left. + private static func argbPixels(of image: CGImage) -> [UInt8]? { + let width = image.width + let height = image.height + var buffer = [UInt8](repeating: 0, count: width * height * 4) + let drawn: Bool = buffer.withUnsafeMutableBytes { raw in + guard let context = CGContext( + data: raw.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return false + } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + return drawn ? buffer : nil + } + + private static func hasInk( + _ pixels: [UInt8], + imageWidth: Int, + imageHeight: Int, + in rect: CGRect + ) -> Bool { + let minX = max(0, Int(rect.minX)) + let maxX = min(imageWidth, Int(rect.maxX)) + let minY = max(0, Int(rect.minY)) + let maxY = min(imageHeight, Int(rect.maxY)) + guard minX < maxX, minY < maxY else { return false } + for y in minY..<maxY { + let rowStart = y * imageWidth * 4 + for x in minX..<maxX where pixels[rowStart + x * 4 + 3] > inkAlphaThreshold { + return true + } + } + return false + } + + /// Re-encodes a tile as PNG, scaling it down to `target` when the source is + /// larger. Tiles smaller than the target are left alone rather than blown + /// up, since upscaling adds bytes without adding detail. + private static func pngData(scaling tile: CGImage, to target: Int) -> Data? { + var image = tile + if tile.width > target { + guard let context = CGContext( + data: nil, + width: target, + height: target, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + context.interpolationQuality = .high + context.draw(tile, in: CGRect(x: 0, y: 0, width: target, height: target)) + guard let scaled = context.makeImage() else { return nil } + image = scaled + } + + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + data, + UTType.png.identifier as CFString, + 1, + nil + ) else { + return nil + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return data as Data + } +} diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift @@ -13,6 +13,12 @@ actor NYTPuzzleFetcher { /// streaming, since the header is optional. static let maxResponseBytes = 2_097_152 + /// Upper bound on an overlay asset. The two real examples are 13 KB and + /// 68 KB — they're mostly transparent — so 1 MB rejects nothing genuine + /// while capping what a mis-served asset can buffer. Anything larger would + /// blow the slicer's own payload budget long before it reached the `.xd`. + static let maxOverlayBytes = 1_048_576 + private let cookieProvider: @Sendable () -> NYTCookieLoadResult init(cookieProvider: @escaping @Sendable () -> NYTCookieLoadResult) { @@ -62,8 +68,37 @@ actor NYTPuzzleFetcher { expectedLength: httpResponse.expectedContentLength ) - // Convert NYT JSON to .xd format - return try NYTToXDConverter.convert(jsonData: data) + // 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) + } + + /// Fetches the puzzle's after-solve 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 + } + do { + let (bytes, response) = try await URLSession.shared.bytes(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + return try await Self.boundedResponseData( + from: bytes, + limit: Self.maxOverlayBytes, + expectedLength: httpResponse.expectedContentLength + ) + } catch { + return nil + } } /// Whether a response `Content-Type` permits JSON. A missing header is diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -7,16 +7,6 @@ enum NYTToXDConverter { var errorDescription: String? { message } } - private enum CellSpecial: String, Hashable { - case circled = "circle" - case shaded - } - - private struct RebusVariant: Hashable { - let answer: String - let special: CellSpecial? - } - /// Whether a cell fill must ride into the grid via a `Rebus:` placeholder /// rather than appear literally. The `.xd` grid is one character per cell and /// the grid parser only takes letters (plus block/special markers and @@ -31,8 +21,42 @@ enum NYTToXDConverter { return !(answer.first?.isLetter ?? false) } + /// The asset the puzzle wants drawn over the completed grid, 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? { + 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 assets = root["assets"] as? [[String: Any]] else { + return nil + } + let position = index - 1 + guard assets.indices.contains(position), + let uri = assets[position]["uri"] as? String, + !uri.isEmpty else { + return nil + } + return URL(string: uri) + } + /// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string. - static func convert(jsonData: Data) throws -> 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 { guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { throw ConversionError(message: "Invalid JSON root.") } @@ -133,27 +157,24 @@ enum NYTToXDConverter { // -- Build rebus header if needed -- - // Each distinct fill-and-special combination claims a grid placeholder. - // Keeping the special in the identity prevents a shaded and unshaded - // occurrence of the same rebus fill from accidentally sharing styling. - var rebusEntries: [(key: Character, variant: RebusVariant)] = [] - var rebusLookup: [RebusVariant: Character] = [:] + // Each distinct fill claims a grid placeholder. The fill alone is the + // identity: circling and shading now ride in the `## Decorations` + // section keyed by position, so a shaded and an unshaded occurrence of + // the same rebus can share one placeholder instead of burning two. + var rebusEntries: [(key: Character, answer: String)] = [] + var rebusLookup: [String: Character] = [:] - for (index, answer) in answers.enumerated() { + for answer in answers { guard let answer, needsRebusEncoding(answer) else { continue } - let variant = RebusVariant( - answer: answer, - special: specialKind(at: index, in: specialCells) - ) - if rebusLookup[variant] == nil { + if rebusLookup[answer] == nil { guard rebusLookup.count < XD.rebusPlaceholders.count else { throw ConversionError( message: "Too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders." ) } let key = XD.rebusPlaceholders[rebusLookup.count] - rebusLookup[variant] = key - rebusEntries.append((key: key, variant: variant)) + rebusLookup[answer] = key + rebusEntries.append((key: key, answer: answer)) } } @@ -169,21 +190,13 @@ enum NYTToXDConverter { continue } if needsRebusEncoding(answer) { - let variant = RebusVariant( - answer: answer, - special: specialKind(at: index, in: specialCells) - ) - line += String(rebusLookup[variant]!) - continue - } - if specialCells.circled.contains(index) { - line += "@" - continue - } - if specialCells.shaded.contains(index) { - line += "*" + line += String(rebusLookup[answer]!) continue } + // A circled or shaded cell keeps its letter in the grid now that + // the decoration is expressed separately. The old `@`/`*` markers + // displaced the fill and left it to be recovered from the clue + // answers; the grid is both more readable and less derived for it. line += answer.uppercased() } gridLines.append(line) @@ -292,20 +305,11 @@ enum NYTToXDConverter { if !rebusEntries.isEmpty { let rebusStr = rebusEntries - .map { "\($0.key)=\(escapeRebusValue($0.variant.answer))" } + .map { "\($0.key)=\(escapeRebusValue($0.answer))" } .joined(separator: " ") metadata.append("Rebus: \(rebusStr)") } - let specialMappings = specialMappings( - circled: specialCells.circled, - shaded: specialCells.shaded, - rebusEntries: rebusEntries - ) - if !specialMappings.isEmpty { - metadata.append("Specials: \(specialMappings)") - } - let relatives = buildRelativeGroups(clues: clues) if !relatives.isEmpty { let joined = relatives @@ -323,6 +327,29 @@ enum NYTToXDConverter { let allClueLines = acrossClueLines + [""] + downClueLines sections.append(allClueLines.joined(separator: "\n")) + // Decorations last: it is optional, named, and looked up by name rather + // than position, so it can only ever be appended. + var allDecorations = decorations( + circled: specialCells.circled, + shaded: specialCells.shaded, + width: width + ) + for (position, tile) in afterSolveDecorations( + image: afterSolveImage, + body: body, + width: width, + height: height + ) { + allDecorations[position, default: []].append(tile) + } + if let decorationSection = try XDDecorationWriter.section( + decorations: allDecorations, + width: width, + height: height + ) { + sections.append(decorationSection) + } + // The .xd parser splits sections on two or more consecutive blank lines, // so we need two blank lines (three newlines) between sections. return sections.joined(separator: "\n\n\n") @@ -653,31 +680,62 @@ enum NYTToXDConverter { return (circled, shaded) } - private static func specialKind( - at index: Int, - in cells: (circled: Set<Int>, shaded: Set<Int>) - ) -> CellSpecial? { - if cells.circled.contains(index) { return .circled } - if cells.shaded.contains(index) { return .shaded } - return nil + /// Slices the after-solve overlay into per-cell `after`-phase decorations. + /// + /// 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( + image: Data?, + body: [String: Any], + width: Int, + height: Int + ) -> [GridPosition: Puzzle.Decoration] { + guard let image, + let boardSVG = body["board"] as? String, + let geometry = NYTOverlaySlicer.geometry(boardSVG: boardSVG), + let tiles = NYTOverlaySlicer.tiles( + imageData: image, + geometry: geometry, + width: width, + height: height + ) else { + return [:] + } + return tiles.mapValues { png in + Puzzle.Decoration( + content: .data( + mimeType: "image/png", + encoding: "base64", + payload: png.base64EncodedString() + ), + phase: .after + ) + } } - private static func specialMappings( + /// Projects the NYT's circled/shaded cell indices onto positions carrying a + /// `mark` decoration. Both are `before`-phase — they're visible from the + /// start, exactly as the `Specials:` header they replace always was. + private static func decorations( circled: Set<Int>, shaded: Set<Int>, - rebusEntries: [(key: Character, variant: RebusVariant)] - ) -> String { - var parts: [String] = [] - if !circled.isEmpty { - parts.append("@=circle") - } - if !shaded.isEmpty { - parts.append("*=shaded") - } - parts += rebusEntries.compactMap { entry in - entry.variant.special.map { "\(entry.key)=\($0.rawValue)" } + width: Int + ) -> [GridPosition: [Puzzle.Decoration]] { + var result: [GridPosition: [Puzzle.Decoration]] = [:] + func add(_ indices: Set<Int>, _ special: Puzzle.Special) { + for index in indices { + let position = GridPosition(row: index / width, col: index % width) + result[position, default: []].append( + Puzzle.Decoration(content: .mark(special), phase: .before) + ) + } } - return parts.joined(separator: " ") + add(circled, .circled) + add(shaded, .shaded) + return result } /// NYT image-based clues mirror the image's aria-label in the `plain` diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -26,11 +26,6 @@ enum PUZToXDConverter { } } - private struct RebusVariant: Hashable { - let answer: String - let circled: Bool - } - /// Upper bound on raw `.puz` bytes accepted by `convert`. Real Across /// Lite files are tens of kilobytes. Independent of `XD.maxSourceBytes` /// because a `.puz` can hide a large ignored tail (string table, @@ -95,21 +90,23 @@ enum PUZToXDConverter { clueTexts: clueTexts ) - // Each distinct rebus fill-and-circle combination claims one grid placeholder from + // Each distinct rebus fill claims one grid placeholder from // `XD.rebusPlaceholders`; cells sharing a fill reuse the same key - // (mirrors `NYTToXDConverter`). Keying by cell index instead would burn - // a fresh character per cell and walk straight into the grid/header - // reserved set (`=`, `@`, ...) and, past ~200 cells, off the end of the - // alphabet — hence the bounds check rather than raw arithmetic. + // (mirrors `NYTToXDConverter`). Circling is no longer part of that + // identity — it rides in the `## Decorations` section keyed by position — + // so a circled and an uncircled occurrence of one fill now share a key. + // Keying by cell index instead would burn a fresh character per cell and + // walk straight into the grid/header reserved set (`=`, `@`, ...) and, + // past ~200 cells, off the end of the alphabet — hence the bounds check + // rather than raw arithmetic. var rebusKeys: [Int: Character] = [:] - var rebusEntries: [(Character, RebusVariant)] = [] - var rebusLookup: [RebusVariant: Character] = [:] + var rebusEntries: [(Character, String)] = [] + var rebusLookup: [String: Character] = [:] for index in 0..<cellCount where isOpen(solutionBytes[index]) { guard let value = rebus[index], !value.isEmpty else { continue } let normalized = value.uppercased() - let variant = RebusVariant(answer: normalized, circled: circledCells.contains(index)) let key: Character - if let existing = rebusLookup[variant] { + if let existing = rebusLookup[normalized] { key = existing } else { guard rebusLookup.count < XD.rebusPlaceholders.count else { @@ -118,8 +115,8 @@ enum PUZToXDConverter { ) } key = XD.rebusPlaceholders[rebusLookup.count] - rebusLookup[variant] = key - rebusEntries.append((key, variant)) + rebusLookup[normalized] = key + rebusEntries.append((key, normalized)) } rebusKeys[index] = key } @@ -130,15 +127,9 @@ enum PUZToXDConverter { if !author.isEmpty { metadata.append("Author: \(author)") } if !copyright.isEmpty { metadata.append("Copyright: \(copyright)") } if !rebusEntries.isEmpty { - let header = rebusEntries.map { "\($0.0)=\($0.1.answer)" }.joined(separator: " ") + let header = rebusEntries.map { "\($0.0)=\($0.1)" }.joined(separator: " ") metadata.append("Rebus: \(header)") } - if !circledCells.isEmpty { - let rebusCircles = rebusEntries - .filter { $0.1.circled } - .map { "\($0.0)=circle" } - metadata.append("Specials: \((["@=circle"] + rebusCircles).joined(separator: " "))") - } let gridLines = (0..<height).map { row -> String in var line = "" @@ -153,10 +144,8 @@ enum PUZToXDConverter { line.append(key) continue } - if circledCells.contains(index) { - line += "@" - continue - } + // A circled cell keeps its letter now that the circle is + // expressed in `## Decorations` rather than displacing the fill. line += String(UnicodeScalar(byte)).uppercased() } return line @@ -169,11 +158,25 @@ enum PUZToXDConverter { .filter { $0.direction == .down } .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } - return [ + var sections = [ metadata.joined(separator: "\n"), gridLines.joined(separator: "\n"), (acrossLines + [""] + downLines).joined(separator: "\n") - ].joined(separator: "\n\n\n") + ] + var decorations: [GridPosition: [Puzzle.Decoration]] = [:] + for index in circledCells { + decorations[GridPosition(row: index / width, col: index % width)] = [ + Puzzle.Decoration(content: .mark(.circled), phase: .before) + ] + } + if let decorationSection = try XDDecorationWriter.section( + decorations: decorations, + width: width, + height: height + ) { + sections.append(decorationSection) + } + return sections.joined(separator: "\n\n\n") } private static func buildClues( diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift @@ -1,31 +1,5 @@ import Foundation -/// A row/column coordinate inside a puzzle grid. Also used as the dictionary -/// key for `GridState` and `MovesValue.cells`. -struct GridPosition: Hashable, Sendable, Codable { - let row: Int - let col: Int -} - -extension GridPosition { - /// `true` when both coordinates survive the trapping `Int16` conversions - /// every Core Data cell/journal sink performs. The remote codecs drop - /// entries that fail this, so a forged record can't persist a value that - /// crashes cache replay on every subsequent fetch. - var isInt16Representable: Bool { - row >= 0 && col >= 0 && row <= Int(Int16.max) && col <= Int(Int16.max) - } - - /// `isInt16Representable` plus grid-shape bounds, for sinks that know the - /// game's dimensions. Zero dimensions (a row that predates the recorded - /// grid size) skip the shape check rather than rejecting everything. - func isPersistable(gridWidth: Int16, gridHeight: Int16) -> Bool { - guard isInt16Representable else { return false } - guard gridWidth > 0, gridHeight > 0 else { return true } - return row < Int(gridHeight) && col < Int(gridWidth) - } -} - /// Per-cell state in the merged grid — the result of `GridStateMerger.merge`. /// `authorID` is the *preserved* cell author from the winning entry. struct GridCell: Equatable, Sendable, Codable { diff --git a/Crossmate/Views/Puzzle/DecorationImages.swift b/Crossmate/Views/Puzzle/DecorationImages.swift @@ -0,0 +1,115 @@ +import CoreGraphics +import ImageIO +import SwiftUI + +/// Decodes `data=` decoration payloads into drawable images. +/// +/// Kept out of the draw path deliberately: `GridView.body` re-runs on every +/// keystroke and cursor move, so decoding there would repeat a base64 decode +/// and a PNG decode per tile per frame. Callers resolve this once per puzzle +/// and hold the result in view state. +/// +/// Keyed by payload rather than by position so identical tiles — a repeated +/// motif in grid art, say — decode once and share. +enum DecorationImages { + static func decode(_ decorations: [GridPosition: [Puzzle.Decoration]]) -> [String: Image] { + // Force the pixel decode to happen here rather than at first draw. + // + // ImageIO defers decoding by default — `kCGImageSourceShouldCacheImmediately` + // is documented as "the default value is kCFBooleanFalse (image decoding + // will happen at rendering time)". For after-solve tiles, "first draw" + // is the instant the puzzle completes and the reveal appears alongside + // the success banner, so the deferred work would land at the one moment + // worth keeping smooth. Decoding here moves it to puzzle load instead. + // + // The decoded form is then retained: `kCGImageSourceShouldCache` + // defaults to true on 64-bit, so this is a one-off cost, not a per-draw + // one. (Local rather than a static because `CFDictionary` isn't + // `Sendable`.) + let decodeOptions = [ + kCGImageSourceShouldCacheImmediately: kCFBooleanTrue + ] as CFDictionary + + var result: [String: Image] = [:] + for layers in decorations.values { + for layer in layers { + guard case .data(let mimeType, let encoding, let payload) = layer.content else { + continue + } + // The format allows any mime type and encoding; we draw the one + // combination converters produce. Anything else is left undrawn + // rather than guessed at. + guard encoding.caseInsensitiveCompare("base64") == .orderedSame, + mimeType.lowercased().hasPrefix("image/"), + result[payload] == nil else { + continue + } + guard let data = Data(base64Encoded: payload), + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, decodeOptions) else { + continue + } + result[payload] = Image(decorative: image, scale: 1) + } + } + return result + } +} + +extension Puzzle.Decoration { + /// Whether this layer is visible given whether the puzzle has been solved. + /// `before` layers are always visible; `after` layers are the reveal. + func isVisible(solved: Bool) -> Bool { + switch phase { + case .before: return true + case .after: return solved + } + } +} + +extension Puzzle.Decoration.Content { + /// Resolves a `#RGB` / `#RRGGBB` / `#RRGGBBAA` value. Returns nil for + /// anything else, so an unparseable colour is skipped rather than drawn as + /// an arbitrary fallback that would look deliberate. + static func color(fromHex value: String) -> Color? { + var hex = value.trimmingCharacters(in: .whitespaces) + if hex.hasPrefix("#") { hex.removeFirst() } + guard hex.allSatisfy(\.isHexDigit) else { return nil } + + let components: [Double] + switch hex.count { + case 3: + components = hex.compactMap { UInt8(String($0), radix: 16) } + .map { Double($0) * 17 / 255 } + case 6, 8: + components = stride(from: 0, to: hex.count, by: 2).compactMap { offset in + let start = hex.index(hex.startIndex, offsetBy: offset) + let end = hex.index(start, offsetBy: 2) + return UInt8(hex[start..<end], radix: 16).map { Double($0) / 255 } + } + default: + return nil + } + guard components.count == max(hex.count / 2, 3) else { return nil } + return Color( + .sRGB, + red: components[0], + green: components[1], + blue: components[2], + opacity: components.count == 4 ? components[3] : 1 + ) + } +} + +extension Puzzle.Decoration.Content { + /// Resolves a `bg=` / `fg=` value for the current appearance. A layer with + /// no dark variant uses its single colour in both. + static func color( + light: String, + dark: String?, + for colorScheme: SwiftUI.ColorScheme + ) -> Color? { + let hex = (colorScheme == .dark ? dark : nil) ?? light + return color(fromHex: hex) + } +} diff --git a/Crossmate/Views/Puzzle/GridView.swift b/Crossmate/Views/Puzzle/GridView.swift @@ -26,6 +26,10 @@ struct GridView: View { /// can map a tap location back to a cell via `PuzzleGridGeometry`. @State private var gridSize: CGSize = .zero + /// Decoded `data=` decoration tiles, keyed by payload. Resolved once per + /// puzzle rather than in `body`, which re-runs on every keystroke. + @State private var decorationImages: [String: Image] = [:] + var body: some View { let width = session.puzzle.width let height = session.puzzle.height @@ -67,10 +71,13 @@ struct GridView: View { let r = index / width let c = index % width let cell = session.puzzle.cells[r][c] + let visibleDecorations = session.puzzle.decorations[GridPosition(row: r, col: c)]? + .filter { $0.isVisible(solved: showsCanonicalRebus) } ?? [] guard !cell.isBlock else { return CellDraw(row: r, col: c, isBlock: true, special: nil, crossRef: nil, entry: "", isPencil: false, - triangle: nil, authorTint: nil) + triangle: nil, authorTint: nil, + decorations: visibleDecorations) } let pos = GridPosition(row: r, col: c) let square = session.game.squares[r][c] @@ -93,7 +100,8 @@ struct GridView: View { entry: displayEntry, isPencil: mark.isPencil, triangle: triangle, - authorTint: entry.isEmpty ? nil : letterAuthorID.flatMap { authorTintByID[$0] } + authorTint: entry.isEmpty ? nil : letterAuthorID.flatMap { authorTintByID[$0] }, + decorations: visibleDecorations ) } // Layered back to front: black (shows through the inter-cell gaps and @@ -125,7 +133,8 @@ struct GridView: View { cells: cellModel, columns: width, rows: height, - spacing: spacing + spacing: spacing, + decorationImages: decorationImages ) } // One tap recogniser for the whole grid instead of 441 per-cell ones: @@ -143,6 +152,13 @@ struct GridView: View { } } .onGeometryChange(for: CGSize.self) { $0.size } action: { gridSize = $0 } + // Decode once per puzzle. Keyed on the game so switching puzzles inside + // one grid view re-resolves rather than drawing the previous puzzle's + // tiles; a puzzle with no `data=` decorations resolves to an empty map + // and costs a single dictionary walk. + .task(id: session.mutator.gameID) { + decorationImages = DecorationImages.decode(session.puzzle.decorations) + } // Synthetic VoiceOver elements over the Canvas layers; built only // while VoiceOver is running (see GridAccessibility.swift). .puzzleGridAccessibility( @@ -193,6 +209,12 @@ private struct CellDraw { /// `AuthorTintsLayer`, not `PuzzleCellsLayer`), matching the former /// `CellView` background. let authorTint: Color? + /// Decoration layers currently visible on this cell, in paint order. Already + /// filtered by phase, so the draw path never has to know whether the puzzle + /// is solved. Unlike every other field here this is also populated for + /// blocks, which is the whole point — an after-solve reveal routinely lands + /// on black squares. + let decorations: [Puzzle.Decoration] } /// The faint author-attribution washes, split out of `PuzzleCellsLayer` so they @@ -238,6 +260,9 @@ private struct PuzzleCellsLayer: View { let columns: Int let rows: Int let spacing: CGFloat + /// Decoded `data=` tiles, keyed by payload (see `DecorationImages`). + var decorationImages: [String: Image] = [:] + @Environment(\.colorScheme) private var colorScheme /// Base size before the per-cell fit-scale; mirrors the former /// `CellView` letter font (`.system(size: 34, weight: .semibold, @@ -255,6 +280,21 @@ private struct PuzzleCellsLayer: View { // full grid has ~26 distinct glyphs, so almost every cell is a hit // and `resolve`/`measure` runs a couple of dozen times, not 441. var glyphCache: [String: (text: GraphicsContext.ResolvedText, size: CGSize)] = [:] + // Resolved tiles are cached for this draw the same way glyphs are: + // a grid art puzzle repeats a handful of motifs across many cells. + var imageCache: [String: GraphicsContext.ResolvedImage] = [:] + + // Decorations run over every cell, blocks included — an after-solve + // reveal routinely lands on black squares, which the content loop + // below skips entirely. + for cell in cells where !cell.decorations.isEmpty { + drawDecorations( + cell, + in: geometry.cellRect(row: cell.row, col: cell.col), + cache: &imageCache, + context: context + ) + } for cell in cells where !cell.isBlock { let rect = geometry.cellRect(row: cell.row, col: cell.col) @@ -288,6 +328,79 @@ private struct PuzzleCellsLayer: View { } } + /// Paints a cell's decoration layers in definition order — the format's + /// stacking rule is that a repeated design character's lines paint in the + /// order they were written. + /// + /// Two kinds are deliberately not drawn here. A `before`-phase `mark` is + /// already folded into `Cell.special` by `Puzzle.init`, so drawing it again + /// would double-stroke every circle; only an `after` mark needs handling. + /// And `fg-*` colours belong to the entry glyph, so they're applied in + /// `drawLetter` rather than as a layer of their own. + private func drawDecorations( + _ cell: CellDraw, + in rect: CGRect, + cache: inout [String: GraphicsContext.ResolvedImage], + context: GraphicsContext + ) { + for decoration in cell.decorations { + switch decoration.content { + case .mark(let special) where decoration.phase == .after: + switch special { + case .shaded: + context.fill(Path(rect), with: .color(.black.opacity(0.22))) + case .circled: + context.stroke( + Path(ellipseIn: rect.insetBy(dx: 1.5, dy: 1.5)), + with: .color(.black.opacity(0.55)), + lineWidth: 1 + ) + } + case .mark: + continue + case .color(.background, let light, let dark): + guard let color = Puzzle.Decoration.Content.color( + light: light, dark: dark, for: colorScheme + ) else { + continue + } + context.fill(Path(rect), with: .color(color)) + case .color: + continue + case .text(let value): + let resolved = context.resolve( + Text(value) + .font(.system(size: baseFontSize * 0.8, weight: .semibold, design: .rounded)) + ) + context.draw(resolved, at: CGPoint(x: rect.midX, y: rect.midY), anchor: .center) + case .data(_, _, let payload): + guard let image = decorationImages[payload] else { continue } + let resolved: GraphicsContext.ResolvedImage + if let hit = cache[payload] { + resolved = hit + } else { + resolved = context.resolve(image) + cache[payload] = resolved + } + context.draw(resolved, in: rect) + } + } + } + + /// The entry colour override from an `fg-*` decoration, if one applies. + private func foregroundOverride(for cell: CellDraw) -> Color? { + for decoration in cell.decorations { + guard case .color(.foreground, let light, let dark) = decoration.content, + let color = Puzzle.Decoration.Content.color( + light: light, dark: dark, for: colorScheme + ) else { + continue + } + return color + } + return nil + } + /// Draws one entry centred in its cell, scaled down to fit exactly as the /// former `.minimumScaleFactor(0.1)` did: the largest size ≤ base that fits /// the cell minus the 2pt horizontal padding. Single letters never scale (a @@ -299,14 +412,18 @@ private struct PuzzleCellsLayer: View { cache: inout [String: (text: GraphicsContext.ResolvedText, size: CGSize)], context: GraphicsContext ) { - let key = "\(cell.entry)|\(cell.isPencil)" + let foreground = foregroundOverride(for: cell) + // The colour joins the cache key: two cells can share an entry and + // differ only in an `fg-*` decoration, and caching on the entry alone + // would paint the second in the first one's colour. + let key = "\(cell.entry)|\(cell.isPencil)|\(foreground.map(String.init(describing:)) ?? "")" let resolved: GraphicsContext.ResolvedText let natural: CGSize if let hit = cache[key] { resolved = hit.text natural = hit.size } else { - let color: Color = cell.isPencil ? .black.opacity(0.5) : .black + let color: Color = foreground ?? (cell.isPencil ? .black.opacity(0.5) : .black) let text = context.resolve( Text(cell.entry) .font(.system(size: baseFontSize, weight: .semibold, design: .rounded)) diff --git a/Scripts/nyt-to-xd.sh b/Scripts/nyt-to-xd.sh @@ -10,26 +10,31 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" -converter="${repo_root}/Crossmate/Services/NYTToXDConverter.swift" -xd_source="${repo_root}/Crossmate/Models/XD.swift" fetch_script="${repo_root}/Scripts/fetch-nyt.sh" -# The converter pulls a couple of constants from the app target (XD). Mirror -# just those from XD.swift so we don't need to compile the full app. Reading -# from source keeps the stub in lockstep with the real values. -converter_version="$(sed -n 's/^[[:space:]]*static let currentConverterVersion = \([0-9][0-9]*\).*/\1/p' "$xd_source" | head -n1)" -if [[ -z "$converter_version" ]]; then - echo "error: could not read currentConverterVersion from $xd_source" >&2 - exit 1 -fi - -# rebusPlaceholders is a self-contained computed constant; extract its whole -# definition (from the declaration through the closing `}()`) verbatim. -rebus_placeholders="$(awk '/static let rebusPlaceholders/{f=1} f{print} f&&/\}\(\)/{exit}' "$xd_source")" -if [[ -z "$rebus_placeholders" ]]; then - echo "error: could not read rebusPlaceholders from $xd_source" >&2 - exit 1 -fi +# The converter's slice of the app target, compiled from the real sources rather +# than mirrored into a stub. This used to hand-copy XD's constants to avoid +# building the whole app, but the converter now also writes the `## Decorations` +# section, which pulls in Puzzle and its neighbours — and a stub of those would +# drift silently. These seven files are self-contained (nothing here reaches into +# Core Data or CloudKit), so compiling them directly costs a second and can't go +# stale. +sources=( + "${repo_root}/Crossmate/Models/XD.swift" + "${repo_root}/Crossmate/Models/XDDecorationWriter.swift" + "${repo_root}/Crossmate/Models/XDMarkup.swift" + "${repo_root}/Crossmate/Models/Puzzle.swift" + "${repo_root}/Crossmate/Models/PlayerSelection.swift" + "${repo_root}/Crossmate/Models/GridPosition.swift" + "${repo_root}/Crossmate/Services/NYTOverlaySlicer.swift" + "${repo_root}/Crossmate/Services/NYTToXDConverter.swift" +) +for source in "${sources[@]}"; do + if [[ ! -f "$source" ]]; then + echo "error: missing source file: $source" >&2 + exit 1 + fi +done json_path="" date_arg="" @@ -70,34 +75,46 @@ 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 ' + (.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="" + fi +fi + driver="${tmp_dir}/main.swift" -cat > "$driver" <<SWIFT +cat > "$driver" <<'SWIFT' import Foundation -// Stub for the symbols the converter pulls from the app target. The values -// are read out of Crossmate/Models/XD.swift at script-start time so this stays -// in sync with the canonical definitions. -enum XD { - static let currentConverterVersion = ${converter_version} - ${rebus_placeholders} -} - guard CommandLine.arguments.count >= 2 else { FileHandle.standardError.write(Data("error: missing JSON path\n".utf8)) exit(2) } let url = URL(fileURLWithPath: CommandLine.arguments[1]) let data = try Data(contentsOf: url) -let xd = try NYTToXDConverter.convert(jsonData: data) +var overlay: Data? = nil +if CommandLine.arguments.count >= 3, !CommandLine.arguments[2].isEmpty { + overlay = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2])) +} +let xd = try NYTToXDConverter.convert(jsonData: data, afterSolveImage: overlay) print(xd) SWIFT binary="${tmp_dir}/nyt-to-xd" -swiftc -O -swift-version 6 "$converter" "$driver" -o "$binary" +swiftc -O -swift-version 6 "${sources[@]}" "$driver" -o "$binary" if [[ -n "$output_path" ]]; then - "$binary" "$json_path" > "$output_path" + "$binary" "$json_path" "$overlay_path" > "$output_path" echo "wrote $output_path" >&2 else - "$binary" "$json_path" + "$binary" "$json_path" "$overlay_path" fi diff --git a/Tests/Unit/DecorationRenderingTests.swift b/Tests/Unit/DecorationRenderingTests.swift @@ -0,0 +1,162 @@ +import CoreGraphics +import Foundation +import ImageIO +import SwiftUI +import Testing +import UniformTypeIdentifiers + +@testable import Crossmate + +@Suite("Decoration rendering") +@MainActor +struct DecorationRenderingTests { + /// A real 8×8 PNG, base64-encoded, so the decode path is exercised against + /// actual image bytes rather than a magic literal. + private static func samplePNGBase64() throws -> String { + let context = try #require(CGContext( + data: nil, width: 8, height: 8, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + let image = try #require(context.makeImage()) + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData( + data, UTType.png.identifier as CFString, 1, nil + )) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + return (data as Data).base64EncodedString() + } + + private static func dataDecoration( + mimeType: String = "image/png", + encoding: String = "base64", + payload: String + ) -> Puzzle.Decoration { + Puzzle.Decoration( + content: .data(mimeType: mimeType, encoding: encoding, payload: payload), + phase: .after + ) + } + + // MARK: - Phase gating + + @Test("before layers are always visible; after layers only once solved") + func phaseGating() { + let before = Puzzle.Decoration(content: .mark(.circled), phase: .before) + let after = Puzzle.Decoration(content: .text("!"), phase: .after) + + #expect(before.isVisible(solved: false)) + #expect(before.isVisible(solved: true)) + #expect(!after.isVisible(solved: false)) + #expect(after.isVisible(solved: true)) + } + + // MARK: - Colour scheme + + @Test("A colour with a dark variant selects on the current appearance") + func darkVariantSelectsOnAppearance() { + let red = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1) + let blue = Color(.sRGB, red: 0, green: 0, blue: 1, opacity: 1) + + #expect(Puzzle.Decoration.Content.color( + light: "#FF0000", dark: "#0000FF", for: .light + ) == red) + #expect(Puzzle.Decoration.Content.color( + light: "#FF0000", dark: "#0000FF", for: .dark + ) == blue) + } + + @Test("A colour with no dark variant is used in both appearances") + func singleColorAppliesToBothAppearances() { + let red = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1) + #expect(Puzzle.Decoration.Content.color(light: "#FF0000", dark: nil, for: .light) == red) + #expect(Puzzle.Decoration.Content.color(light: "#FF0000", dark: nil, for: .dark) == red) + } + + // MARK: - Hex colours + + @Test("Hex colours parse in their three accepted lengths") + func hexColorsParse() { + let red = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1) + #expect(Puzzle.Decoration.Content.color(fromHex: "#FF0000") == red) + #expect(Puzzle.Decoration.Content.color(fromHex: "#F00") == red) + #expect(Puzzle.Decoration.Content.color(fromHex: "FF0000") == red) + #expect(Puzzle.Decoration.Content.color(fromHex: "#ff0000") == red) + + let halfRed = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 0.5) + let parsed = Puzzle.Decoration.Content.color(fromHex: "#FF000080") + #expect(parsed != nil) + // 0x80/255 is 0.502, so compare against the same construction rather + // than a rounded literal. + #expect(parsed == Color(.sRGB, red: 1, green: 0, blue: 0, opacity: Double(0x80) / 255)) + #expect(parsed != halfRed) + } + + @Test("An unparseable hex colour is skipped rather than guessed at") + func unparseableHexColorsAreNil() { + #expect(Puzzle.Decoration.Content.color(fromHex: "") == nil) + #expect(Puzzle.Decoration.Content.color(fromHex: "#") == nil) + #expect(Puzzle.Decoration.Content.color(fromHex: "#GGGGGG") == nil) + #expect(Puzzle.Decoration.Content.color(fromHex: "#FF00") == nil) + #expect(Puzzle.Decoration.Content.color(fromHex: "rebeccapurple") == nil) + } + + // MARK: - Image decoding + + @Test("A base64 PNG payload decodes to a drawable image") + func base64PNGDecodes() throws { + let payload = try Self.samplePNGBase64() + let images = DecorationImages.decode([ + GridPosition(row: 0, col: 0): [Self.dataDecoration(payload: payload)] + ]) + #expect(images.count == 1) + #expect(images[payload] != nil) + } + + @Test("Identical payloads on different cells decode once") + func identicalPayloadsDecodeOnce() throws { + let payload = try Self.samplePNGBase64() + let images = DecorationImages.decode([ + GridPosition(row: 0, col: 0): [Self.dataDecoration(payload: payload)], + GridPosition(row: 3, col: 4): [Self.dataDecoration(payload: payload)] + ]) + #expect(images.count == 1) + } + + @Test("Unsupported encodings and mime types are left undrawn") + func unsupportedPayloadsAreSkipped() throws { + let payload = try Self.samplePNGBase64() + let images = DecorationImages.decode([ + GridPosition(row: 0, col: 0): [ + Self.dataDecoration(encoding: "hex", payload: payload), + Self.dataDecoration(mimeType: "text/plain", payload: payload) + ] + ]) + #expect(images.isEmpty) + } + + @Test("A payload that isn't valid base64 or isn't an image is skipped") + func malformedPayloadsAreSkipped() { + let images = DecorationImages.decode([ + GridPosition(row: 0, col: 0): [ + Self.dataDecoration(payload: "not base64!!"), + Self.dataDecoration(payload: Data("not a png".utf8).base64EncodedString()) + ] + ]) + #expect(images.isEmpty) + } + + @Test("Non-data decorations contribute no images") + func nonDataDecorationsContributeNoImages() { + let images = DecorationImages.decode([ + GridPosition(row: 0, col: 0): [ + Puzzle.Decoration(content: .mark(.shaded), phase: .before), + Puzzle.Decoration(content: .text("x"), phase: .after) + ] + ]) + #expect(images.isEmpty) + } +} diff --git a/Tests/Unit/NYTOverlaySlicerTests.swift b/Tests/Unit/NYTOverlaySlicerTests.swift @@ -0,0 +1,238 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +@testable import Crossmate + +@Suite("NYT overlay slicer") +@MainActor +struct NYTOverlaySlicerTests { + /// A board SVG in the shape the NYT actually serves: a `viewBox` plus a + /// first cell rect that gives the margin and the cell pitch. + private static func boardSVG(border: Int, cell: Int, cols: Int) -> String { + let side = border * 2 + cell * cols + return """ + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 \(side) \(side)">\ + <g class="cells"><g data-index="0">\ + <path d="M\(border) \(border)h\(cell)v\(cell)H\(border)z" fill="none" class="cell"/>\ + </g></g></svg> + """ + } + + /// Draws an overlay-shaped image: fully transparent except for an opaque + /// square filling each of `inked`. + private static func overlayImage( + border: Int, + cell: Int, + cols: Int, + rows: Int, + scale: Int, + inked: [GridPosition] + ) throws -> Data { + let width = (border * 2 + cell * cols) * scale + let height = (border * 2 + cell * rows) * scale + let context = try #require(CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + 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)) + 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. + 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 + ) + context.fill(rect) + } + let image = try #require(context.makeImage()) + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData( + data, UTType.png.identifier as CFString, 1, nil + )) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } + + // MARK: - Geometry + + @Test("Geometry is read from the board SVG, not assumed") + func geometryIsReadFromBoardSVG() throws { + // A 15×15 ships 33-unit cells in a 501 viewBox… + let fifteen = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 15) + )) + #expect(fifteen.border == 3) + #expect(fifteen.cellSize == 33) + #expect(fifteen.viewBoxWidth == 501) + + // …while a 21×21 ships 23-unit cells in a 489 viewBox. Assuming either + // shape misaligns every tile on the other. + let twentyOne = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 23, cols: 21) + )) + #expect(twentyOne.border == 3) + #expect(twentyOne.cellSize == 23) + #expect(twentyOne.viewBoxWidth == 489) + } + + @Test("Geometry is nil when the board SVG is unusable") + func geometryIsNilForUnusableSVG() { + #expect(NYTOverlaySlicer.geometry(boardSVG: "") == nil) + #expect(NYTOverlaySlicer.geometry(boardSVG: "<svg viewBox=\"0 0 501 501\"></svg>") == nil) + #expect(NYTOverlaySlicer.geometry(boardSVG: "<path d=\"M3 3h33v33H3z\"/>") == nil) + } + + // MARK: - Slicing + + @Test("Only cells carrying ink become tiles") + func onlyInkedCellsBecomeTiles() throws { + let inked = [GridPosition(row: 0, col: 1), GridPosition(row: 2, col: 2)] + let image = try Self.overlayImage( + border: 3, cell: 33, cols: 3, rows: 3, scale: 4, inked: inked + ) + let geometry = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) + )) + + let tiles = try #require(NYTOverlaySlicer.tiles( + imageData: image, geometry: geometry, width: 3, height: 3 + )) + #expect(Set(tiles.keys) == Set(inked)) + } + + @Test("A fully transparent overlay yields no tiles") + func transparentOverlayYieldsNoTiles() throws { + let image = try Self.overlayImage( + border: 3, cell: 33, cols: 3, rows: 3, scale: 4, inked: [] + ) + let geometry = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) + )) + + let tiles = try #require(NYTOverlaySlicer.tiles( + imageData: image, geometry: geometry, width: 3, height: 3 + )) + #expect(tiles.isEmpty) + } + + @Test("Tiles are emitted as PNGs downscaled to the stored size") + func tilesArePNGsAtStoredSize() throws { + let image = try Self.overlayImage( + border: 3, cell: 33, cols: 3, rows: 3, scale: 8, inked: [GridPosition(row: 1, col: 1)] + ) + let geometry = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) + )) + let tiles = try #require(NYTOverlaySlicer.tiles( + imageData: image, geometry: geometry, width: 3, height: 3 + )) + let png = try #require(tiles[GridPosition(row: 1, col: 1)]) + + // A 33-unit cell at scale 8 is 264px, comfortably above the stored size. + let source = try #require(CGImageSourceCreateWithData(png as CFData, nil)) + #expect(CGImageSourceGetType(source) as String? == UTType.png.identifier) + let decoded = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + #expect(decoded.width == NYTOverlaySlicer.tileSize) + #expect(decoded.height == NYTOverlaySlicer.tileSize) + } + + @Test("Geometry that doesn't fit the image is rejected") + func geometryNotFittingImageIsRejected() throws { + // A 3×3 image told it is a 15×15 grid: the derived cells run off the + // end, so every tile would be cut from the wrong place. + let image = try Self.overlayImage( + border: 3, cell: 33, cols: 3, rows: 3, scale: 4, inked: [GridPosition(row: 0, col: 0)] + ) + let geometry = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) + )) + #expect(NYTOverlaySlicer.tiles( + imageData: image, geometry: geometry, width: 15, height: 15 + ) == nil) + } + + @Test("Unreadable image data yields nil") + func unreadableImageYieldsNil() throws { + let geometry = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 33, cols: 3) + )) + #expect(NYTOverlaySlicer.tiles( + imageData: Data("not an image".utf8), geometry: geometry, width: 3, height: 3 + ) == nil) + } + + @Test("An overlay past the payload budget is dropped whole") + func oversizedOverlayIsDroppedWhole() throws { + // Nine tiles of incompressible noise clear the 96 KB budget several + // times over, so a 3×3 grid is enough — no need to build a huge one. + let image = try Self.noiseOverlay(border: 3, cell: 23, cols: 3, scale: 4) + let geometry = try #require(NYTOverlaySlicer.geometry( + boardSVG: Self.boardSVG(border: 3, cell: 23, cols: 3) + )) + #expect(NYTOverlaySlicer.tiles( + imageData: image, geometry: geometry, width: 3, height: 3 + ) == nil) + } + + /// A fully-inked overlay of random pixels. + /// + /// Noise is deliberate: flat colour compresses to almost nothing, so it + /// would never reach the payload budget this exercises. The buffer is + /// filled directly rather than through thousands of `CGContext.fill` calls + /// — this suite runs in parallel with timing-sensitive tests elsewhere, and + /// a fixture that saturates a core for seconds can starve their deadlines. + private static func noiseOverlay( + border: Int, + cell: Int, + cols: Int, + scale: Int + ) throws -> Data { + let side = (border * 2 + cell * cols) * scale + var bytes = [UInt8](repeating: 255, count: side * side * 4) + var generator = SystemRandomNumberGenerator() + for index in stride(from: 0, to: bytes.count, by: 4) { + bytes[index] = UInt8.random(in: 0...255, using: &generator) + bytes[index + 1] = UInt8.random(in: 0...255, using: &generator) + bytes[index + 2] = UInt8.random(in: 0...255, using: &generator) + // bytes[index + 3] stays 255: every cell must count as inked. + } + + let image: CGImage = try bytes.withUnsafeMutableBytes { raw in + let context = try #require(CGContext( + data: raw.baseAddress, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + return try #require(context.makeImage()) + } + + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData( + data, UTType.png.identifier as CFString, 1, nil + )) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } +} diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -343,16 +343,22 @@ struct NYTToXDConverterTests { #expect(puzzle.cells[0][3].solution == "B") } - @Test("NYT type 2 cells emit circled specials") - func typeTwoCellsEmitCircledSpecials() throws { + @Test("NYT type 2 cells emit circle decorations and keep their letters") + func typeTwoCellsEmitCircleDecorations() throws { let data = try puzzleJSON( relatives: [nil, nil, nil, nil, nil, nil], cellTypes: [0: 2, 4: 2] ) let xd = try NYTToXDConverter.convert(jsonData: data) - #expect(header("Specials", in: xd) == "@=circle") - #expect(xd.contains("\n@BC\nD@F\nGHI\n")) + // The circle no longer displaces the fill: the grid reads normally and + // the decoration rides in its own section. + #expect(header("Specials", in: xd) == nil) + #expect(xd.contains("\nABC\nDEF\nGHI\n")) + #expect(xd.contains("## Decorations")) + #expect(xd.contains("\nO..\n.O.\n...\n")) + #expect(xd.contains("O. mark=circle")) + let puzzle = Puzzle(xd: try XD.parse(xd)) #expect(puzzle.cells[0][0].special == .circled) #expect(puzzle.cells[1][1].special == .circled) @@ -360,31 +366,36 @@ struct NYTToXDConverterTests { #expect(puzzle.cells[1][1].solution == "E") } - @Test("NYT type 3 cells emit shaded specials") - func typeThreeCellsEmitShadedSpecials() throws { + @Test("NYT type 3 cells emit shaded decorations") + func typeThreeCellsEmitShadedDecorations() throws { let data = try puzzleJSON( relatives: [nil, nil, nil, nil, nil, nil], cellTypes: [0: 3, 4: 3] ) let xd = try NYTToXDConverter.convert(jsonData: data) - #expect(header("Specials", in: xd) == "*=shaded") - #expect(xd.contains("\n*BC\nD*F\nGHI\n")) + #expect(header("Specials", in: xd) == nil) + #expect(xd.contains("\nABC\nDEF\nGHI\n")) + #expect(xd.contains("\nS..\n.S.\n...\n")) + #expect(xd.contains("S. mark=shaded")) + let puzzle = Puzzle(xd: try XD.parse(xd)) #expect(puzzle.cells[0][0].special == .shaded) #expect(puzzle.cells[1][1].special == .shaded) } - @Test("Mixed type 2 and type 3 cells emit separate special masks") - func mixedSpecialTypesEmitSeparateMasks() throws { + @Test("Mixed type 2 and type 3 cells share one design grid") + func mixedSpecialTypesShareOneDesignGrid() throws { let data = try puzzleJSON( relatives: [nil, nil, nil, nil, nil, nil], cellTypes: [0: 2, 4: 3] ) let xd = try NYTToXDConverter.convert(jsonData: data) - #expect(headers("Specials", in: xd) == ["@=circle *=shaded"]) - #expect(xd.contains("\n@BC\nD*F\nGHI\n")) + #expect(xd.contains("\nO..\n.S.\n...\n")) + #expect(xd.contains("O. mark=circle")) + #expect(xd.contains("S. mark=shaded")) + let puzzle = Puzzle(xd: try XD.parse(xd)) #expect(puzzle.cells[0][0].special == .circled) #expect(puzzle.cells[1][1].special == .shaded) @@ -400,16 +411,22 @@ struct NYTToXDConverterTests { let xd = try NYTToXDConverter.convert(jsonData: data) #expect(header("Rebus", in: xd) == "1=HEART") - #expect(header("Specials", in: xd) == "@=circle 1=circle") - #expect(xd.contains("\n@BC\nD1F\nGHI\n")) + #expect(xd.contains("\nABC\nD1F\nGHI\n")) + #expect(xd.contains("\nO..\n.O.\n...\n")) + #expect(xd.contains("O. mark=circle")) + let puzzle = Puzzle(xd: try XD.parse(xd)) #expect(puzzle.cells[1][1].solution == "HEART") #expect(puzzle.cells[0][0].special == .circled) #expect(puzzle.cells[1][1].special == .circled) } - @Test("Shaded and unshaded occurrences of one rebus use separate placeholders") - func shadedAndUnshadedRebusOccurrencesUseSeparatePlaceholders() throws { + @Test("Shaded and unshaded occurrences of one rebus share a placeholder") + func shadedAndUnshadedRebusOccurrencesShareAPlaceholder() throws { + // Shading used to be part of a rebus placeholder's identity, because the + // marker displaced the grid character. Now that it rides in the design + // grid keyed by position, one fill needs only one placeholder however + // many of its occurrences are decorated. let data = try puzzleJSON( relatives: [nil, nil, nil, nil, nil, nil], letters: ["HEART", "B", "C", "D", "HEART", "F", "G", "H", "I"], @@ -417,9 +434,11 @@ struct NYTToXDConverterTests { ) let xd = try NYTToXDConverter.convert(jsonData: data) - #expect(header("Rebus", in: xd) == "1=HEART 2=HEART") - #expect(header("Specials", in: xd) == "*=shaded 1=shaded") - #expect(xd.contains("\n1BC\nD2F\nGHI\n")) + #expect(header("Rebus", in: xd) == "1=HEART") + #expect(xd.contains("\n1BC\nD1F\nGHI\n")) + #expect(xd.contains("\nS..\n...\n...\n")) + #expect(xd.contains("S. mark=shaded")) + let puzzle = Puzzle(xd: try XD.parse(xd)) #expect(puzzle.cells[0][0].solution == "HEART") #expect(puzzle.cells[0][0].special == .shaded) @@ -825,4 +844,53 @@ struct NYTToXDConverterTests { #expect(related.contains(GridPosition(row: 2, col: c))) } } + + // MARK: - After-solve overlay + + private func overlayJSON(afterSolve: Any?, assetURIs: [String]) throws -> Data { + var body: [String: Any] = ["cells": [], "clues": []] + if let afterSolve { + body["overlays"] = ["afterSolve": afterSolve] + } + let root: [String: Any] = [ + "body": [body], + "assets": assetURIs.map { ["uri": $0] } + ] + return try JSONSerialization.data(withJSONObject: root) + } + + @Test("afterSolve is a one-based index into assets") + func afterSolveIndexIsOneBased() throws { + let data = try overlayJSON( + afterSolve: 2, + assetURIs: ["https://example.test/start.png", "https://example.test/solve.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"]) + #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil) + } + + @Test("An afterSolve index outside the asset list resolves to no URL") + func outOfRangeAfterSolveIndexResolvesToNil() 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( + afterSolve: index, + assetURIs: ["https://example.test/a.png", "https://example.test/b.png"] + ) + #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: [""]) + #expect(try NYTToXDConverter.afterSolveImageURL(jsonData: data) == nil) + } } diff --git a/Tests/Unit/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift @@ -109,8 +109,8 @@ struct PUZToXDConverterTests { #expect(puzzle.title == "Mini PUZ") } - @Test("Circled cells emit explicit special mask") - func circledCellsEmitExplicitSpecialMask() throws { + @Test("Circled cells emit a decorations section and keep their letters") + func circledCellsEmitDecorationsSection() throws { let data = try puzData( width: 3, height: 3, @@ -130,8 +130,11 @@ struct PUZToXDConverterTests { ) let source = try PUZToXDConverter.convert(puzData: data) - #expect(source.contains("Specials: @=circle")) - #expect(source.contains("\n@BC\nD@F\nGHI\n")) + #expect(!source.contains("Specials:")) + #expect(source.contains("\nABC\nDEF\nGHI\n")) + #expect(source.contains("## Decorations")) + #expect(source.contains("\nO..\n.O.\n...\n")) + #expect(source.contains("O. mark=circle")) let puzzle = Puzzle(xd: try XD.parse(source)) #expect(puzzle.cells[0][0].special == .circled) @@ -230,8 +233,10 @@ struct PUZToXDConverterTests { let source = try PUZToXDConverter.convert(puzData: data) #expect(header("Rebus", in: source) == "1=HEART") - #expect(header("Specials", in: source) == "@=circle 1=circle") - #expect(source.contains("\n@BC\nD1F\nGHI\n")) + #expect(header("Specials", in: source) == nil) + #expect(source.contains("\nABC\nD1F\nGHI\n")) + #expect(source.contains("\nO..\n.O.\n...\n")) + #expect(source.contains("O. mark=circle")) let puzzle = Puzzle(xd: try XD.parse(source)) #expect(puzzle.cells[1][1].solution == "HEART") diff --git a/Tests/Unit/XDDecorationTests.swift b/Tests/Unit/XDDecorationTests.swift @@ -0,0 +1,424 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("XD Decorations section") +@MainActor +struct XDDecorationTests { + /// A 3x3 puzzle whose grid and clues stay fixed across these tests so each + /// case varies only the `## Decorations` section under examination. + private static func source(decorations: String?) -> String { + let base = """ + Title: Decorations + + + ABC + DEF + GHI + + + A1. Row 1 ~ ABC + A4. Row 2 ~ DEF + A7. Row 3 ~ GHI + D1. Col 1 ~ ADG + D2. Col 2 ~ BEH + D3. Col 3 ~ CFI + """ + guard let decorations else { return base } + return base + "\n\n\n## Decorations\n\n" + decorations + } + + // MARK: - Absence + + @Test("A source with no Decorations section parses with no decorations") + func absentSectionYieldsNoDecorations() throws { + let xd = try XD.parse(Self.source(decorations: nil)) + #expect(xd.decorations.isEmpty) + #expect(Puzzle(xd: xd).decorations.isEmpty) + } + + // MARK: - Marks and the Specials migration + + @Test("mark definitions populate Cell.special, replacing the Specials header") + func markPopulatesCellSpecial() throws { + let puzzle = Puzzle(xd: try XD.parse(Self.source(decorations: """ + O.S + ... + ..O + + O. mark=circle + S. mark=shaded + """))) + + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[0][2].special == .shaded) + #expect(puzzle.cells[2][2].special == .circled) + #expect(puzzle.cells[0][1].special == nil) + #expect(puzzle.cells[1][1].special == nil) + // The fill still comes from the grid, untouched by the decoration. + #expect(puzzle.cells[0][0].solution == "A") + } + + @Test("mark accepts both circle and circled") + func markAcceptsBothCircleSpellings() throws { + let puzzle = Puzzle(xd: try XD.parse(Self.source(decorations: """ + AB. + ... + ... + + A. mark=circle + B. mark=circled + """))) + + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[0][1].special == .circled) + } + + @Test("An after-phase mark does not become a Cell.special") + func afterPhaseMarkIsNotACellSpecial() throws { + let xd = try XD.parse(Self.source(decorations: """ + O.. + ... + ... + + O. mark=circle after + """)) + let puzzle = Puzzle(xd: xd) + + // It is still carried as a decoration; it just isn't visible from the + // start, so it must not feed the always-drawn `special` path. + #expect(puzzle.cells[0][0].special == nil) + #expect(puzzle.decorations[GridPosition(row: 0, col: 0)]?.count == 1) + } + + // MARK: - Stacking + + @Test("Repeating a character stacks layers in definition order") + func repeatedCharacterStacksLayers() throws { + let xd = try XD.parse(Self.source(decorations: """ + 4.. + ... + ... + + 4. text=🌈 + 4. bg=#00AA00 after + """)) + + let layers = try #require(xd.decorations[GridPosition(row: 0, col: 0)]) + #expect(layers.count == 2) + #expect(layers[0] == Puzzle.Decoration(content: .text("🌈"), phase: .before)) + #expect(layers[1] == Puzzle.Decoration( + content: .color(layer: .background, light: "#00AA00", dark: nil), + phase: .after + )) + } + + @Test("A stacked character applies every layer to each of its cells") + func stackedCharacterAppliesToAllItsCells() throws { + let xd = try XD.parse(Self.source(decorations: """ + X.X + ... + ... + + X. mark=shaded + X. text=! + """)) + + #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.count == 2) + #expect(xd.decorations[GridPosition(row: 0, col: 2)]?.count == 2) + #expect(xd.decorations[GridPosition(row: 0, col: 1)] == nil) + } + + // MARK: - Phase + + @Test("Phase defaults to before when the keyword is omitted") + func phaseDefaultsToBefore() throws { + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. text=x + """)) + #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.first?.phase == .before) + } + + @Test("An explicit before keyword is accepted") + func explicitBeforeIsAccepted() throws { + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. text=x before + """)) + let layer = try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first) + #expect(layer.phase == .before) + #expect(layer.content == .text("x")) + } + + // MARK: - Colours + + @Test("bg and fg carry their appearance variants in the value") + func colorKindsCarryVariantsInTheValue() throws { + let xd = try XD.parse(Self.source(decorations: """ + 12. + 34. + ... + + 1. bg=#111111 + 2. bg=#222222;#333333 + 3. fg=#444444 + 4. fg=#555555;#666666 + """)) + + func content(_ row: Int, _ col: Int) throws -> Puzzle.Decoration.Content { + try #require(xd.decorations[GridPosition(row: row, col: col)]?.first?.content) + } + + // A lone colour applies to both appearances, so `dark` stays nil rather + // than being filled in with a copy. + #expect(try content(0, 0) == .color(layer: .background, light: "#111111", dark: nil)) + #expect(try content(0, 1) == .color(layer: .background, light: "#222222", dark: "#333333")) + #expect(try content(1, 0) == .color(layer: .foreground, light: "#444444", dark: nil)) + #expect(try content(1, 1) == .color(layer: .foreground, light: "#555555", dark: "#666666")) + } + + @Test("A colour value with the wrong number of parts is skipped") + func malformedColorValueIsSkipped() throws { + let xd = try XD.parse(Self.source(decorations: """ + 12. + 3.. + ... + + 1. bg=#111111;#222222;#333333 + 2. bg=#111111; + 3. bg=;#222222 + """)) + // Structurally sound lines, so the section still parses and the design + // characters still resolve — the layers themselves are just dropped. + #expect(xd.decorations.isEmpty) + } + + // MARK: - Text + + @Test("A text value keeps its internal spaces") + func textValueKeepsInternalSpaces() throws { + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. text=NEW YORK after + """)) + let layer = try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first) + #expect(layer.content == .text("NEW YORK")) + #expect(layer.phase == .after) + } + + // MARK: - Data + + @Test("A data value splits into mime type, encoding and payload") + func dataValueSplitsIntoParts() throws { + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. data=image/png;base64,iVBORw0KGgo= after + """)) + let layer = try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first) + #expect(layer.content == .data( + mimeType: "image/png", + encoding: "base64", + payload: "iVBORw0KGgo=" + )) + #expect(layer.phase == .after) + } + + @Test("A data value with no encoding defaults to base64") + func dataValueDefaultsToBase64() throws { + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. data=image/png,iVBORw0KGgo= + """)) + #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.first?.content == .data( + mimeType: "image/png", + encoding: "base64", + payload: "iVBORw0KGgo=" + )) + } + + @Test("Base64 padding does not confuse the key/value split") + func base64PaddingSurvivesTheSplit() throws { + // The value is split on the *first* `=`, so trailing padding stays in + // the payload rather than being read as another key/value boundary. + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. data=image/png;base64,QUJDRA== + """)) + guard case .data(_, _, let payload) = + try #require(xd.decorations[GridPosition(row: 0, col: 0)]?.first?.content) else { + Issue.record("expected a data decoration") + return + } + #expect(payload == "QUJDRA==") + } + + // MARK: - Forward compatibility + + @Test("An unrecognised kind is skipped without failing the section") + func unrecognisedKindIsSkipped() throws { + let xd = try XD.parse(Self.source(decorations: """ + AB. + ... + ... + + A. sparkle=magenta + B. mark=shaded + """)) + + // The character is still 'defined', so its design-grid cell resolves + // instead of throwing — a newer spec revision must not break the file. + #expect(xd.decorations[GridPosition(row: 0, col: 0)] == nil) + #expect(xd.decorations[GridPosition(row: 0, col: 1)]?.count == 1) + } + + @Test("An unrecognised mark value is skipped without failing the section") + func unrecognisedMarkValueIsSkipped() throws { + let xd = try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. mark=hexagon + """)) + #expect(xd.decorations.isEmpty) + } + + // MARK: - Failures + + @Test("A design grid of the wrong size is rejected") + func wrongSizedDesignGridIsRejected() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(Self.source(decorations: """ + A.. + ... + + A. mark=circle + """)) + } + } + + @Test("A design grid with a ragged row is rejected") + func raggedDesignGridIsRejected() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(Self.source(decorations: """ + A.. + .... + ... + + A. mark=circle + """)) + } + } + + @Test("A design character with no definition is rejected") + func undefinedDesignCharacterIsRejected() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(Self.source(decorations: """ + A.Z + ... + ... + + A. mark=circle + """)) + } + } + + @Test("A definition line with no key/value pair is rejected") + func definitionWithoutPairIsRejected() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + A. circle + """)) + } + } + + @Test("A definition line with a multi-character key is rejected") + func multiCharacterKeyIsRejected() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(Self.source(decorations: """ + A.. + ... + ... + + AB. mark=circle + """)) + } + } + + // MARK: - Section handling + + @Test("The grid may follow the definitions") + func gridMayFollowDefinitions() throws { + // Grid and definition lines are told apart by whitespace, not order. + let xd = try XD.parse(Self.source(decorations: """ + A. mark=circle + + A.. + ... + ... + """)) + #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.count == 1) + } + + @Test("An unrecognised named section is ignored") + func unrecognisedNamedSectionIsIgnored() throws { + let source = Self.source(decorations: """ + A.. + ... + ... + + A. mark=circle + """) + "\n\n\n## Notes\n\nSome trailing prose." + + let xd = try XD.parse(source) + #expect(xd.decorations[GridPosition(row: 0, col: 0)]?.count == 1) + #expect(xd.acrossClues.count == 3) + } + + @Test("An all-block grid row is not mistaken for a section header") + func allBlockRowIsNotASectionHeader() throws { + // `#` is the block character, so a row of blocks superficially looks + // like a `##` section marker. + let xd = try XD.parse(""" + Title: Blocks + + + ABC + ### + DEF + + + A1. Row 1 ~ ABC + A4. Row 3 ~ DEF + D1. Col 1 ~ A + D2. Col 2 ~ B + D3. Col 3 ~ C + """) + #expect(xd.height == 3) + #expect(xd.width == 3) + } +}