diff --git a/OSRMTextInstructions.xcodeproj/project.pbxproj b/OSRMTextInstructions.xcodeproj/project.pbxproj index fb6a67d..e046fc4 100644 --- a/OSRMTextInstructions.xcodeproj/project.pbxproj +++ b/OSRMTextInstructions.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 358D145A1E5E355600ADE590 /* OSRMTextInstructions.h in Headers */ = {isa = PBXBuildFile; fileRef = 358D14571E5E355600ADE590 /* OSRMTextInstructions.h */; settings = {ATTRIBUTES = (Public, ); }; }; 358D145B1E5E355600ADE590 /* OSRMTextInstructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 358D14581E5E355600ADE590 /* OSRMTextInstructions.swift */; }; C51B63E91E65FA04002F4634 /* TokenType.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51B63E81E65FA04002F4634 /* TokenType.swift */; }; + DA5F02781F6CBAAF0040C4AD /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5F02771F6CBAAF0040C4AD /* TokenTests.swift */; }; + DA5F02791F6CBAAF0040C4AD /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5F02771F6CBAAF0040C4AD /* TokenTests.swift */; }; + DA5F027A1F6CBAAF0040C4AD /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5F02771F6CBAAF0040C4AD /* TokenTests.swift */; }; DA5F589C1E85A32C00BA4D0A /* OSRMTextInstructions.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA5F58931E85A32C00BA4D0A /* OSRMTextInstructions.framework */; }; DA5F58AB1E85B0A000BA4D0A /* OSRMTextInstructions.h in Headers */ = {isa = PBXBuildFile; fileRef = 358D14571E5E355600ADE590 /* OSRMTextInstructions.h */; settings = {ATTRIBUTES = (Public, ); }; }; DA5F58AC1E85B0A500BA4D0A /* OSRMTextInstructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 358D14581E5E355600ADE590 /* OSRMTextInstructions.swift */; }; @@ -80,6 +83,7 @@ 35EBDB5D1E5E1572006EB3CD /* OSRMTextInstructions.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OSRMTextInstructions.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C51B63E81E65FA04002F4634 /* TokenType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenType.swift; sourceTree = ""; }; DA2DFB3B1E8373AF00CEEBE9 /* json2plist.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = json2plist.sh; sourceTree = ""; }; + DA5F02771F6CBAAF0040C4AD /* TokenTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenTests.swift; sourceTree = SOURCE_ROOT; }; DA5F58931E85A32C00BA4D0A /* OSRMTextInstructions.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OSRMTextInstructions.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DA5F589B1E85A32C00BA4D0A /* OSRMTextInstructionsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OSRMTextInstructionsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA5F58B61E85B24E00BA4D0A /* MapboxDirections.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapboxDirections.framework; path = Carthage/Build/Mac/MapboxDirections.framework; sourceTree = ""; }; @@ -177,8 +181,9 @@ 352BBC2A1E5E4D4200703DF1 /* OSRMTextInstructionsTests */ = { isa = PBXGroup; children = ( - 352BBC2B1E5E4D4200703DF1 /* OSRMTextInstructionsTests.swift */, 352BBC2D1E5E4D4200703DF1 /* Info.plist */, + 352BBC2B1E5E4D4200703DF1 /* OSRMTextInstructionsTests.swift */, + DA5F02771F6CBAAF0040C4AD /* TokenTests.swift */, ); path = OSRMTextInstructionsTests; sourceTree = ""; @@ -637,6 +642,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA5F02781F6CBAAF0040C4AD /* TokenTests.swift in Sources */, 352BBC2C1E5E4D4200703DF1 /* OSRMTextInstructionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -665,6 +671,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA5F02791F6CBAAF0040C4AD /* TokenTests.swift in Sources */, DA5F58AE1E85B0AE00BA4D0A /* OSRMTextInstructionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -683,6 +690,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA5F027A1F6CBAAF0040C4AD /* TokenTests.swift in Sources */, DA5F58E61E85BE1900BA4D0A /* OSRMTextInstructionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OSRMTextInstructions.xcodeproj/xcshareddata/xcschemes/OSRMTextInstructions macOS.xcscheme b/OSRMTextInstructions.xcodeproj/xcshareddata/xcschemes/OSRMTextInstructions macOS.xcscheme index a1c8589..01293fa 100644 --- a/OSRMTextInstructions.xcodeproj/xcshareddata/xcschemes/OSRMTextInstructions macOS.xcscheme +++ b/OSRMTextInstructions.xcodeproj/xcshareddata/xcschemes/OSRMTextInstructions macOS.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/OSRMTextInstructions/OSRMTextInstructions.swift b/OSRMTextInstructions/OSRMTextInstructions.swift index 2526058..7aaf730 100644 --- a/OSRMTextInstructions/OSRMTextInstructions.swift +++ b/OSRMTextInstructions/OSRMTextInstructions.swift @@ -4,14 +4,20 @@ import MapboxDirections // Will automatically read localized Instructions.plist let OSRMTextInstructionsStrings = NSDictionary(contentsOfFile: Bundle(for: OSRMInstructionFormatter.self).path(forResource: "Instructions", ofType: "plist")!)! -extension String { - public var sentenceCased: String { - return String(characters.prefix(1)).uppercased() + String(characters.dropFirst()) - } +protocol Tokenized { + associatedtype T /** Replaces `{tokens}` in the receiver using the given closure. */ + func replacingTokens(using interpolator: ((TokenType) -> T)) -> T +} + +extension String: Tokenized { + public var sentenceCased: String { + return String(characters.prefix(1)).uppercased() + String(characters.dropFirst()) + } + public func replacingTokens(using interpolator: ((TokenType) -> String)) -> String { let scanner = Scanner(string: self) scanner.charactersToBeSkipped = nil @@ -28,15 +34,18 @@ extension String { var token: NSString? guard scanner.scanUpTo("}", into: &token) else { + result += "{" continue } if scanner.scanString("}", into: nil) { if let tokenType = TokenType(description: token! as String) { result += interpolator(tokenType) + } else { + result += "{\(token!)}" } } else { - result += token! as String + result += "{\(token!)" } } @@ -52,6 +61,48 @@ extension String { } } +extension NSAttributedString: Tokenized { + public func replacingTokens(using interpolator: ((TokenType) -> NSAttributedString)) -> NSAttributedString { + let scanner = Scanner(string: string) + scanner.charactersToBeSkipped = nil + let result = NSMutableAttributedString() + while !scanner.isAtEnd { + var buffer: NSString? + + if scanner.scanUpTo("{", into: &buffer) { + result.append(NSAttributedString(string: buffer! as String)) + } + guard scanner.scanString("{", into: nil) else { + continue + } + + var token: NSString? + guard scanner.scanUpTo("}", into: &token) else { + continue + } + + if scanner.scanString("}", into: nil) { + if let tokenType = TokenType(description: token! as String) { + result.append(interpolator(tokenType)) + } + } else { + result.append(NSAttributedString(string: token! as String)) + } + } + + // remove excess spaces + let wholeRange = NSRange(location: 0, length: result.mutableString.length) + result.mutableString.replaceOccurrences(of: "\\s\\s", with: " ", options: .regularExpression, range: wholeRange) + + // capitalize + let meta = OSRMTextInstructionsStrings["meta"] as! [String: Any] + if meta["capitalizeFirstLetter"] as? Bool ?? false { + result.replaceCharacters(in: NSRange(location: 0, length: 1), with: String(result.string.characters.first!).uppercased()) + } + return result as NSAttributedString + } +} + public class OSRMInstructionFormatter: Formatter { let version: String let instructions: [String: Any] @@ -179,7 +230,7 @@ public class OSRMInstructionFormatter: Formatter { /** Creates an instruction given a step and options. - - parameter step: + - parameter step: The step to format. - parameter legIndex: Current leg index the user is currently on. - parameter numberOfLegs: Total number of `RouteLeg` for the given `Route`. - parameter roadClasses: Option set representing the classes of road for the `RouteStep`. @@ -187,6 +238,31 @@ public class OSRMInstructionFormatter: Formatter { - returns: An instruction as a `String`. */ public func string(for obj: Any?, legIndex: Int?, numberOfLegs: Int?, roadClasses: RoadClasses? = RoadClasses([]), modifyValueByKey: ((TokenType, String) -> String)?) -> String? { + guard let obj = obj else { + return nil + } + + var modifyAttributedValueByKey: ((TokenType, NSAttributedString) -> NSAttributedString)? + if let modifyValueByKey = modifyValueByKey { + modifyAttributedValueByKey = { (key: TokenType, value: NSAttributedString) -> NSAttributedString in + return NSAttributedString(string: modifyValueByKey(key, value.string)) + } + } + return attributedString(for: obj, legIndex: legIndex, numberOfLegs: numberOfLegs, roadClasses: roadClasses, modifyValueByKey: modifyAttributedValueByKey)?.string + } + + /** + Creates an instruction as an attributed string given a step and options. + + - parameter obj: The step to format. + - parameter attrs: The default attributes to use for the returned attributed string. + - parameter legIndex: Current leg index the user is currently on. + - parameter numberOfLegs: Total number of `RouteLeg` for the given `Route`. + - parameter roadClasses: Option set representing the classes of road for the `RouteStep`. + - parameter modifyValueByKey: Allows for mutating the instruction at given parts of the instruction. + - returns: An instruction as an `NSAttributedString`. + */ + public func attributedString(for obj: Any, withDefaultAttributes attrs: [String : Any]? = nil, legIndex: Int?, numberOfLegs: Int?, roadClasses: RoadClasses? = RoadClasses([]), modifyValueByKey: ((TokenType, NSAttributedString) -> NSAttributedString)?) -> NSAttributedString? { guard let step = obj as? RouteStep else { return nil } @@ -208,14 +284,14 @@ public class OSRMInstructionFormatter: Formatter { var instructionObject: InstructionsByToken var rotaryName = "" - var wayName: String + var wayName: NSAttributedString switch type { case .takeRotary, .takeRoundabout: // Special instruction types have an intermediate level keyed to “default”. let instructionsByModifier = instructions[type.description] as! [String: InstructionsByModifier] let defaultInstructions = instructionsByModifier["default"]! - wayName = step.exitNames?.first ?? "" + wayName = NSAttributedString(string: step.exitNames?.first ?? "", attributes: attrs) if let _rotaryName = step.names?.first, let _ = step.exitIndex, let obj = defaultInstructions["name_exit"] { instructionObject = obj rotaryName = _rotaryName @@ -244,22 +320,42 @@ public class OSRMInstructionFormatter: Formatter { let isMotorway = roadClasses?.contains(.motorway) ?? false if let name = name, let ref = ref, name != ref, !isMotorway { - wayName = phrase(named: .nameWithCode).replacingTokens(using: { (tokenType) -> String in + let attributedName = NSAttributedString(string: name, attributes: attrs) + let attributedRef = NSAttributedString(string: ref, attributes: attrs) + let phrase = NSAttributedString(string: self.phrase(named: .nameWithCode), attributes: attrs) + wayName = phrase.replacingTokens(using: { (tokenType) -> NSAttributedString in switch tokenType { case .wayName: - return modifyValueByKey?(.wayName, name) ?? name + return modifyValueByKey?(.wayName, attributedName) ?? attributedName case .code: - return modifyValueByKey?(.code, ref) ?? ref + return modifyValueByKey?(.code, attributedRef) ?? attributedRef default: fatalError("Unexpected token type \(tokenType) in name-and-ref phrase") } }) } else if let ref = ref, isMotorway, let decimalRange = ref.rangeOfCharacter(from: .decimalDigits), !decimalRange.isEmpty { - wayName = modifyValueByKey != nil ? "\(modifyValueByKey!(.code, ref))" : ref + let attributedRef = NSAttributedString(string: ref, attributes: attrs) + if let modifyValueByKey = modifyValueByKey { + wayName = modifyValueByKey(.code, attributedRef) + } else { + wayName = attributedRef + } } else if name == nil, let ref = ref { - wayName = modifyValueByKey != nil ? "\(modifyValueByKey!(.code, ref))" : ref + let attributedRef = NSAttributedString(string: ref, attributes: attrs) + if let modifyValueByKey = modifyValueByKey { + wayName = modifyValueByKey(.code, attributedRef) + } else { + wayName = attributedRef + } + } else if let name = name { + let attributedName = NSAttributedString(string: name, attributes: attrs) + if let modifyValueByKey = modifyValueByKey { + wayName = modifyValueByKey(.wayName, attributedName) + } else { + wayName = attributedName + } } else { - wayName = name != nil ? modifyValueByKey != nil ? "\(modifyValueByKey!(.wayName, name!))" : name! : "" + wayName = NSAttributedString() } } @@ -292,7 +388,7 @@ public class OSRMInstructionFormatter: Formatter { instruction = obj } else if let _ = step.exitCodes?.first, let obj = instructionObject["exit"] { instruction = obj - } else if !wayName.isEmpty, let obj = instructionObject["name"] { + } else if !wayName.string.isEmpty, let obj = instructionObject["name"] { instruction = obj } else { instruction = instructionObject["default"]! @@ -315,11 +411,11 @@ public class OSRMInstructionFormatter: Formatter { if step.finalHeading != nil { bearing = Int(step.finalHeading! as Double) } // Replace tokens - let result = instruction.replacingTokens { (tokenType) -> String in + let result = NSAttributedString(string: instruction, attributes: attrs).replacingTokens { (tokenType) -> NSAttributedString in var replacement: String switch tokenType { case .code: replacement = step.codes?.first ?? "" - case .wayName: replacement = wayName + case .wayName: replacement = "" // ignored case .destination: replacement = destination case .exitCode: replacement = exitCode case .exitIndex: replacement = exitOrdinal @@ -332,9 +428,10 @@ public class OSRMInstructionFormatter: Formatter { fatalError("Unexpected token type \(tokenType) in individual instruction") } if tokenType == .wayName { - return replacement // already modified above + return wayName // already modified above } else { - return modifyValueByKey?(tokenType, replacement) ?? replacement + let attributedReplacement = NSAttributedString(string: replacement, attributes: attrs) + return modifyValueByKey?(tokenType, attributedReplacement) ?? attributedReplacement } } diff --git a/TokenTests.swift b/TokenTests.swift new file mode 100644 index 0000000..e158dea --- /dev/null +++ b/TokenTests.swift @@ -0,0 +1,26 @@ +import XCTest +import OSRMTextInstructions + +class TokenTests: XCTestCase { + func testReplacingTokens() { + XCTAssertEqual("Dead Beef", "Dead Beef".replacingTokens { _ in "" }) + XCTAssertEqual("Food", "F{ref}{ref}d".replacingTokens { _ in "o" }) + + XCTAssertEqual("Take the left stairs to the 20th floor", "Take the {modifier} stairs to the {nth} floor".replacingTokens { (tokenType) -> String in + switch tokenType { + case .modifier: + return "left" + case .wayPoint: + return "20th" + default: + XCTAssert(false) + return "wrong" + } + }) + + XCTAssertEqual("{👿}", "{👿}".replacingTokens { _ in "👼" }) + XCTAssertEqual("{", "{".replacingTokens { _ in "🕳" }) + XCTAssertEqual("{💣", "{💣".replacingTokens { _ in "🕳" }) + XCTAssertEqual("}", "}".replacingTokens { _ in "🕳" }) + } +}