Skip to content

Attributed string formatting #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Sep 18, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion OSRMTextInstructions.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
DA2DFB3B1E8373AF00CEEBE9 /* json2plist.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = json2plist.sh; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -177,8 +181,9 @@
352BBC2A1E5E4D4200703DF1 /* OSRMTextInstructionsTests */ = {
isa = PBXGroup;
children = (
352BBC2B1E5E4D4200703DF1 /* OSRMTextInstructionsTests.swift */,
352BBC2D1E5E4D4200703DF1 /* Info.plist */,
352BBC2B1E5E4D4200703DF1 /* OSRMTextInstructionsTests.swift */,
DA5F02771F6CBAAF0040C4AD /* TokenTests.swift */,
);
path = OSRMTextInstructionsTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -637,6 +642,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA5F02781F6CBAAF0040C4AD /* TokenTests.swift in Sources */,
352BBC2C1E5E4D4200703DF1 /* OSRMTextInstructionsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -665,6 +671,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA5F02791F6CBAAF0040C4AD /* TokenTests.swift in Sources */,
DA5F58AE1E85B0AE00BA4D0A /* OSRMTextInstructionsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand All @@ -683,6 +690,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA5F027A1F6CBAAF0040C4AD /* TokenTests.swift in Sources */,
DA5F58E61E85BE1900BA4D0A /* OSRMTextInstructionsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES">
<Testables>
<TestableReference
skipped = "NO">
Expand Down
135 changes: 116 additions & 19 deletions OSRMTextInstructions/OSRMTextInstructions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!)"
}
}

Expand All @@ -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]
Expand Down Expand Up @@ -179,14 +230,39 @@ 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`.
- parameter modifyValueByKey: Allows for mutating the instruction at given parts of the instruction.
- 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
}
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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"]!
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down
26 changes: 26 additions & 0 deletions TokenTests.swift
Original file line number Diff line number Diff line change
@@ -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 "🕳" })
}
}