crossmate

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

commit 8062849feb3bcefef09c9dcf3122da39e22b1755
parent eeaa7d15015c74f63cd0fe0faf3ab030a43b6222
Author: Michael Camilleri <[email protected]>
Date:   Thu,  7 May 2026 17:09:20 +0900

Support Across Lite puzzle import

This commit adds an Across Lite file format (i.e. .puz) converter that reads
the binary header, solution grid, metadata, clue table, circled-cell extension,
and rebus extensions, then emits the same .xd source format the rest of the app
already stores and syncs.

The import paths now accept both .xd and .puz files, including direct document
opens and files listed from the iCloud Drive import folder. The app's document
type declarations and file importer content types include the Across Lite UTI.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/Info.plist | 18++++++++++++++++++
MCrossmate/Models/XDFileType.swift | 1+
MCrossmate/Services/DriveMonitor.swift | 18++++++++++++++----
MCrossmate/Services/ImportService.swift | 11+++++++++--
ACrossmate/Services/PUZToXDConverter.swift | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/ImportedBrowseView.swift | 4++--
ATests/Unit/PUZToXDConverterTests.swift | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 8++++++++
9 files changed, 569 insertions(+), 8 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -71,11 +71,13 @@ C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; + C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */; }; CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; }; CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; + CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */; }; CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; }; @@ -169,6 +171,7 @@ B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.swift; sourceTree = "<group>"; }; B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; }; + B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverter.swift; sourceTree = "<group>"; }; B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBufferTests.swift; sourceTree = "<group>"; }; @@ -195,6 +198,7 @@ F7422F19AA1F1692A98E3602 /* MoveLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLog.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; + FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -245,6 +249,7 @@ C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, + FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */, C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */, @@ -386,6 +391,7 @@ A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, + B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */, ); path = Services; sourceTree = "<group>"; @@ -508,6 +514,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, + C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, @@ -562,6 +569,7 @@ CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */, DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */, 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */, + CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */, 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */, diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -18,6 +18,7 @@ <key>LSItemContentTypes</key> <array> <string>net.inqk.crossmate.xd</string> + <string>com.litsoft.puz</string> </array> </dict> </array> @@ -87,6 +88,23 @@ </array> </dict> </dict> + <dict> + <key>UTTypeConformsTo</key> + <array> + <string>public.data</string> + </array> + <key>UTTypeDescription</key> + <string>Across Lite Puzzle</string> + <key>UTTypeIdentifier</key> + <string>com.litsoft.puz</string> + <key>UTTypeTagSpecification</key> + <dict> + <key>public.filename-extension</key> + <array> + <string>puz</string> + </array> + </dict> + </dict> </array> </dict> </plist> diff --git a/Crossmate/Models/XDFileType.swift b/Crossmate/Models/XDFileType.swift @@ -2,4 +2,5 @@ import UniformTypeIdentifiers extension UTType { static let xdPuzzle = UTType(importedAs: "net.inqk.crossmate.xd") + static let acrossLitePuzzle = UTType(importedAs: "com.litsoft.puz") } diff --git a/Crossmate/Services/DriveMonitor.swift b/Crossmate/Services/DriveMonitor.swift @@ -48,7 +48,10 @@ final class DriveMonitor { guard containerAvailable, !query.isStarted else { return } query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] - query.predicate = NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.xd") + query.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [ + NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.xd"), + NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.puz") + ]) query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)] let center = NotificationCenter.default @@ -88,7 +91,14 @@ final class DriveMonitor { coordinator.coordinate(readingItemAt: url, options: [], error: &readError) { readURL in do { - result = try String(contentsOf: readURL, encoding: .utf8) + switch readURL.pathExtension.lowercased() { + case "xd": + result = try String(contentsOf: readURL, encoding: .utf8) + case "puz": + result = try PUZToXDConverter.convert(puzData: Data(contentsOf: readURL)) + default: + throw CocoaError(.fileReadUnsupportedScheme) + } } catch { innerError = error } @@ -167,9 +177,9 @@ final class DriveMonitor { let readme = documents.appendingPathComponent("README.txt") guard !FileManager.default.fileExists(atPath: readme.path) else { return } let body = """ - Drop .xd crossword files into this folder to import them into Crossmate. + Drop .xd or .puz crossword files into this folder to import them into Crossmate. - Crossmate will list any .xd files you place here (including inside subfolders) in the Imported tab of the New Game sheet. + Crossmate will list any .xd or .puz files you place here (including inside subfolders) in the Imported tab of the New Game sheet. """ try? body.data(using: .utf8)?.write(to: readme, options: .atomic) } diff --git a/Crossmate/Services/ImportService.swift b/Crossmate/Services/ImportService.swift @@ -11,7 +11,7 @@ final class ImportService { } func importGame(from url: URL) -> UUID? { - guard url.pathExtension.lowercased() == "xd" else { return nil } + guard ["xd", "puz"].contains(url.pathExtension.lowercased()) else { return nil } let needsAccess = url.startAccessingSecurityScopedResource() defer { @@ -22,7 +22,14 @@ final class ImportService { let source: String do { - source = try String(contentsOf: url, encoding: .utf8) + switch url.pathExtension.lowercased() { + case "xd": + source = try String(contentsOf: url, encoding: .utf8) + case "puz": + source = try PUZToXDConverter.convert(puzData: Data(contentsOf: url)) + default: + return nil + } } catch { return nil } diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -0,0 +1,358 @@ +import Foundation + +/// Converts Across Lite `.puz` files to Crossmate's `.xd` source format. +enum PUZToXDConverter { + struct ConversionError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private struct ClueEntry { + let number: Int + let direction: Direction + let cells: [Int] + let text: String + } + + private enum Direction { + case across + case down + + var prefix: String { + switch self { + case .across: "A" + case .down: "D" + } + } + } + + static func convert(puzData data: Data) throws -> String { + guard data.count >= 0x34 else { + throw ConversionError(message: "Across Lite file is too short.") + } + guard asciiString(in: data, range: 0x02..<0x0D) == "ACROSS&DOWN" else { + throw ConversionError(message: "Not an Across Lite puzzle.") + } + + let width = Int(data[0x2C]) + let height = Int(data[0x2D]) + let clueCount = Int(littleEndianUInt16(in: data, at: 0x2E)) + guard width > 0, height > 0 else { + throw ConversionError(message: "Across Lite puzzle has invalid dimensions.") + } + + let cellCount = width * height + let solutionStart = 0x34 + let fillStart = solutionStart + cellCount + let stringsStart = fillStart + cellCount + guard data.count >= stringsStart else { + throw ConversionError(message: "Across Lite grid is incomplete.") + } + + let solutionBytes = Array(data[solutionStart..<fillStart]) + let stringTable = try parseNullTerminatedStrings( + in: data, + from: stringsStart, + count: clueCount + 4 + ) + guard stringTable.strings.count >= clueCount + 3 else { + throw ConversionError(message: "Across Lite string table is incomplete.") + } + + let title = displayTitle(fromPUZTitle: stringTable.strings[0]) + let author = stringTable.strings[1] + let copyright = stringTable.strings[2] + let clueTexts = Array(stringTable.strings[3..<(3 + clueCount)]) + let extensions = parseExtensions(in: data, from: stringTable.endOffset) + let rebus = parseRebus(extensions: extensions, cellCount: cellCount) + let circledCells = parseCircledCells(extensions: extensions, cellCount: cellCount) + + let entries = try buildClues( + solutionBytes: solutionBytes, + width: width, + height: height, + clueTexts: clueTexts + ) + + var rebusKeys: [Int: Character] = [:] + var rebusEntries: [(Character, String)] = [] + var nextRebusKey: UInt8 = Character("1").asciiValue! + for index in 0..<cellCount where isOpen(solutionBytes[index]) { + guard let value = rebus[index], !value.isEmpty else { continue } + guard rebusKeys[index] == nil else { continue } + let key = Character(UnicodeScalar(nextRebusKey)) + rebusKeys[index] = key + rebusEntries.append((key, value.uppercased())) + nextRebusKey += 1 + } + + var metadata: [String] = [] + if !title.isEmpty { metadata.append("Title: \(title)") } + 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)" }.joined(separator: " ") + metadata.append("Rebus: \(header)") + } + if !circledCells.isEmpty { + metadata.append("Special: circle") + } + + let gridLines = (0..<height).map { row -> String in + var line = "" + for col in 0..<width { + let index = row * width + col + let byte = solutionBytes[index] + guard isOpen(byte) else { + line += "#" + continue + } + if let key = rebusKeys[index] { + line.append(key) + continue + } + let letter = String(UnicodeScalar(byte)) + line += circledCells.contains(index) ? letter.lowercased() : letter.uppercased() + } + return line + } + + let acrossLines = entries + .filter { $0.direction == .across } + .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } + let downLines = entries + .filter { $0.direction == .down } + .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } + + return [ + metadata.joined(separator: "\n"), + gridLines.joined(separator: "\n"), + (acrossLines + [""] + downLines).joined(separator: "\n") + ].joined(separator: "\n\n\n") + } + + private static func buildClues( + solutionBytes: [UInt8], + width: Int, + height: Int, + clueTexts: [String] + ) throws -> [ClueEntry] { + var entries: [ClueEntry] = [] + var clueIndex = 0 + var number = 1 + + for row in 0..<height { + for col in 0..<width { + let index = row * width + col + guard isOpen(solutionBytes[index]) else { continue } + + let startsAcross = !isOpen(solutionBytes, row: row, col: col - 1, width: width, height: height) + && isOpen(solutionBytes, row: row, col: col + 1, width: width, height: height) + let startsDown = !isOpen(solutionBytes, row: row - 1, col: col, width: width, height: height) + && isOpen(solutionBytes, row: row + 1, col: col, width: width, height: height) + + if startsAcross || startsDown { + if startsAcross { + guard clueTexts.indices.contains(clueIndex) else { + throw ConversionError(message: "Across Lite clue table ended early.") + } + entries.append(ClueEntry( + number: number, + direction: .across, + cells: wordCells(fromRow: row, col: col, deltaRow: 0, deltaCol: 1, width: width, height: height, solutionBytes: solutionBytes), + text: clueTexts[clueIndex] + )) + clueIndex += 1 + } + if startsDown { + guard clueTexts.indices.contains(clueIndex) else { + throw ConversionError(message: "Across Lite clue table ended early.") + } + entries.append(ClueEntry( + number: number, + direction: .down, + cells: wordCells(fromRow: row, col: col, deltaRow: 1, deltaCol: 0, width: width, height: height, solutionBytes: solutionBytes), + text: clueTexts[clueIndex] + )) + clueIndex += 1 + } + number += 1 + } + } + } + + guard clueIndex == clueTexts.count else { + throw ConversionError(message: "Across Lite clue table has unused clues.") + } + return entries + } + + private static func clueLine( + _ entry: ClueEntry, + solutionBytes: [UInt8], + rebus: [Int: String] + ) -> String { + let answer = entry.cells.map { index -> String in + if let rebusValue = rebus[index], !rebusValue.isEmpty { + return rebusValue + } + return String(UnicodeScalar(solutionBytes[index])) + }.joined().uppercased() + return "\(entry.direction.prefix)\(entry.number). \(entry.text) ~ \(answer)" + } + + private static func wordCells( + fromRow row: Int, + col: Int, + deltaRow: Int, + deltaCol: Int, + width: Int, + height: Int, + solutionBytes: [UInt8] + ) -> [Int] { + var cells: [Int] = [] + var r = row + var c = col + while isOpen(solutionBytes, row: r, col: c, width: width, height: height) { + cells.append(r * width + c) + r += deltaRow + c += deltaCol + } + return cells + } + + private static func parseNullTerminatedStrings( + in data: Data, + from start: Int, + count: Int + ) throws -> (strings: [String], endOffset: Int) { + var strings: [String] = [] + var offset = start + while strings.count < count && offset < data.count { + guard let end = data[offset...].firstIndex(of: 0) else { break } + strings.append(decodeString(data[offset..<end])) + offset = data.index(after: end) + } + guard strings.count == count else { + throw ConversionError(message: "Across Lite string table is incomplete.") + } + return (strings, offset) + } + + private static func displayTitle(fromPUZTitle title: String) -> String { + let letters = title.unicodeScalars.filter { CharacterSet.letters.contains($0) } + guard !letters.isEmpty, + letters.allSatisfy({ CharacterSet.uppercaseLetters.contains($0) }) + else { return title } + + var result = "" + var startOfWord = true + for scalar in title.unicodeScalars { + if CharacterSet.letters.contains(scalar) { + let string = String(scalar) + result += startOfWord ? string.uppercased() : string.lowercased() + startOfWord = false + } else { + result += String(scalar) + startOfWord = !CharacterSet.decimalDigits.contains(scalar) + } + } + return result + } + + private static func parseExtensions(in data: Data, from start: Int) -> [String: Data] { + var extensions: [String: Data] = [:] + var offset = start + while offset + 8 <= data.count { + guard let code = String(data: data[offset..<(offset + 4)], encoding: .ascii) else { break } + let length = Int(littleEndianUInt16(in: data, at: offset + 4)) + let payloadStart = offset + 8 + let payloadEnd = payloadStart + length + guard payloadEnd <= data.count else { break } + extensions[code] = data[payloadStart..<payloadEnd] + offset = payloadEnd + if offset < data.count, data[offset] == 0 { + offset += 1 + } + } + return extensions + } + + private static func parseCircledCells( + extensions: [String: Data], + cellCount: Int + ) -> Set<Int> { + guard let data = extensions["GEXT"], data.count >= cellCount else { return [] } + var cells: Set<Int> = [] + for index in 0..<cellCount where data[index] & 0x80 != 0 { + cells.insert(index) + } + return cells + } + + private static func parseRebus( + extensions: [String: Data], + cellCount: Int + ) -> [Int: String] { + guard let grid = extensions["GRBS"], grid.count >= cellCount else { return [:] } + let table = parseRebusTable(extensions["RTBL"]) + var rebus: [Int: String] = [:] + for index in 0..<cellCount { + let key = Int(grid[index]) + guard key > 0, let value = table[key] else { continue } + rebus[index] = value + } + return rebus + } + + private static func parseRebusTable(_ data: Data?) -> [Int: String] { + guard let data else { return [:] } + let source = decodeString(data) + var table: [Int: String] = [:] + for rawEntry in source.split(separator: ";") { + let entry = rawEntry.trimmingCharacters(in: .whitespacesAndNewlines) + guard let colon = entry.firstIndex(of: ":"), + let key = Int(entry[..<colon].trimmingCharacters(in: .whitespaces)) + else { continue } + let value = entry[entry.index(after: colon)...].trimmingCharacters(in: .whitespaces) + if !value.isEmpty { + table[key] = value + } + } + return table + } + + private static func isOpen(_ byte: UInt8) -> Bool { + byte != UInt8(ascii: ".") && byte != 0 + } + + private static func isOpen( + _ solutionBytes: [UInt8], + row: Int, + col: Int, + width: Int, + height: Int + ) -> Bool { + guard row >= 0, row < height, col >= 0, col < width else { return false } + return isOpen(solutionBytes[row * width + col]) + } + + private static func littleEndianUInt16(in data: Data, at offset: Int) -> UInt16 { + UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) + } + + private static func asciiString(in data: Data, range: Range<Int>) -> String? { + guard data.count >= range.upperBound else { return nil } + return String(data: data[range], encoding: .ascii) + } + + private static func decodeString(_ data: Data) -> String { + if let value = String(data: data, encoding: .windowsCP1252) { + return value.trimmingCharacters(in: .newlines) + } + if let value = String(data: data, encoding: .isoLatin1) { + return value.trimmingCharacters(in: .newlines) + } + return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines) + } +} diff --git a/Crossmate/Views/ImportedBrowseView.swift b/Crossmate/Views/ImportedBrowseView.swift @@ -26,7 +26,7 @@ struct ImportedBrowseView: View { ContentUnavailableView { Label("No Imported Puzzles", systemImage: "folder") } description: { - Text("Add .xd files to the Crossmate folder in Files, or tap the import button to bring one in.") + Text("Add .xd or .puz files to the Crossmate folder in Files, or tap the import button to bring one in.") } } } @@ -42,7 +42,7 @@ struct ImportedBrowseView: View { } .fileImporter( isPresented: $showingImporter, - allowedContentTypes: [.xdPuzzle], + allowedContentTypes: [.xdPuzzle, .acrossLitePuzzle], allowsMultipleSelection: false ) { result in handleImport(result) diff --git a/Tests/Unit/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PUZToXDConverter") +struct PUZToXDConverterTests { + @Test("Across Lite binary converts to XD and parses") + func basicPuzzleConvertsAndParses() throws { + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Mini PUZ", + author: "Tester", + copyright: "\u{00A9}2026", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ] + ) + + let source = try PUZToXDConverter.convert(puzData: data) + let xd = try XD.parse(source) + let puzzle = Puzzle(xd: xd) + + #expect(puzzle.title == "Mini PUZ") + #expect(puzzle.author == "Tester") + #expect(xd.copyright == "\u{00A9}2026") + #expect(puzzle.width == 3) + #expect(puzzle.height == 3) + #expect(puzzle.acrossClues.map(\.number) == [1, 4, 5]) + #expect(puzzle.downClues.map(\.number) == [1, 2, 3]) + #expect(xd.acrossClues.first?.answer == "ABC") + #expect(xd.downClues.first?.answer == "ADG") + } + + @Test("Blocks are translated to XD blocks") + func blocksConvert() throws { + let data = try puzData( + width: 3, + height: 3, + solution: "ABC.D.EFG", + title: "Blocks", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 2", + "Across 4" + ] + ) + + let source = try PUZToXDConverter.convert(puzData: data) + #expect(source.contains("ABC\n#D#\nEFG")) + + let puzzle = Puzzle(xd: try XD.parse(source)) + #expect(puzzle.cells[1][0].isBlock) + #expect(puzzle.cells[1][2].isBlock) + } + + @Test("All-uppercase PUZ title is displayed in title case") + func uppercaseTitleIsTitleCased() throws { + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "THEMELESS MONDAY #875", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ] + ) + + let puzzle = Puzzle(xd: try XD.parse(try PUZToXDConverter.convert(puzData: data))) + #expect(puzzle.title == "Themeless Monday #875") + } + + @Test("Mixed-case PUZ title is preserved") + func mixedCaseTitleIsPreserved() throws { + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Mini PUZ", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ] + ) + + let puzzle = Puzzle(xd: try XD.parse(try PUZToXDConverter.convert(puzData: data))) + #expect(puzzle.title == "Mini PUZ") + } + + private func puzData( + width: UInt8, + height: UInt8, + solution: String, + title: String, + author: String, + copyright: String, + clues: [String], + notes: String = "" + ) throws -> Data { + let cellCount = Int(width) * Int(height) + let solutionBytes = Array(solution.utf8) + #expect(solutionBytes.count == cellCount) + + var data = Data(repeating: 0, count: 0x34) + for (offset, byte) in "ACROSS&DOWN".utf8.enumerated() { + data[0x02 + offset] = byte + } + data[0x18] = UInt8(ascii: "1") + data[0x19] = UInt8(ascii: ".") + data[0x1A] = UInt8(ascii: "3") + data[0x2C] = width + data[0x2D] = height + data[0x2E] = UInt8(clues.count & 0xFF) + data[0x2F] = UInt8((clues.count >> 8) & 0xFF) + + data.append(contentsOf: solutionBytes) + data.append(contentsOf: Array(repeating: UInt8(ascii: "-"), count: cellCount)) + + for string in [title, author, copyright] + clues + [notes] { + data.append(try cp1252Data(string)) + data.append(0) + } + return data + } + + private func cp1252Data(_ string: String) throws -> Data { + try #require(string.data(using: .windowsCP1252)) + } +} diff --git a/project.yml b/project.yml @@ -50,11 +50,19 @@ targets: UTTypeTagSpecification: public.filename-extension: - xd + - UTTypeIdentifier: com.litsoft.puz + UTTypeDescription: Across Lite Puzzle + UTTypeConformsTo: + - public.data + UTTypeTagSpecification: + public.filename-extension: + - puz CFBundleDocumentTypes: - CFBundleTypeName: Crossmate Puzzle LSHandlerRank: Owner LSItemContentTypes: - net.inqk.crossmate.xd + - com.litsoft.puz CKSharingSupported: true LSSupportsOpeningDocumentsInPlace: false UILaunchScreen: {}