Skip to content

Instantly share code, notes, and snippets.

@brettohland
Last active October 23, 2023 20:47
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save brettohland/744fcbd2a8aa77907ec84a286e8da3b0 to your computer and use it in GitHub Desktop.
Save brettohland/744fcbd2a8aa77907ec84a286e8da3b0 to your computer and use it in GitHub Desktop.
ISBN example with FormatStyle, AttributedStringFormatStyle, ParseableFormatStyle conformance.

Supporting FormatStyle & ParseableFormatStyle To Your Custom Types

A full example of adding String and AttributedString output to our custom types, as well as adding the ability to parse String values into your custom type.

Read the blog post

See the Xcode Playground

ko-fi


Another questionable project by Ampersand Softworks

import Foundation
/// Represents a 13 digit International Standard Book Number.
public struct ISBN: Codable, Sendable, Equatable, Hashable {
public let prefix: String
public let registrationGroup: String
public let registrant: String
public let publication: String
public let checkDigit: String
/// Initializes a new ISBN struct
/// - Parameters:
/// - prefix: The prefix to the registration group
/// - registrationGroup: The registration group (as numbers)
/// - registrant: The registrant (as number)
/// - publication: The publication (as numbers)
/// - checkDigit: The check digit used in validation
public init(
prefix: String,
registrationGroup: String,
registrant: String,
publication: String,
checkDigit: String
) {
self.prefix = prefix
self.registrationGroup = registrationGroup
self.registrant = registrant
self.publication = publication
self.checkDigit = checkDigit
}
}
import Foundation
public extension ISBN {
struct FormatStyle: Codable, Equatable, Hashable {
/// Defines which ISBN standard to output
public enum Standard: Codable, Equatable, Hashable {
/// ISBN-13
case isbn13
/// ISBN-10
case isbn10
}
public enum Separator: String, Codable, Equatable, Hashable {
case hyphen = "-"
case space = " "
case none = ""
}
let standard: Standard
let separator: Separator
/// Initialize an ISBN FormatStyle with the given Standard
/// - Parameter standard: Standard, defaults to .isbn13(.hyphen)
public init(_ standard: Standard = .isbn13, separator: Separator = .hyphen) {
self.standard = standard
self.separator = separator
}
// MARK: Customization Method Chaining
public func standard(_ standard: Standard) -> Self {
.init(standard, separator: separator)
}
/// Returns a new instance of `self` with the standard property set.
/// - Parameter standard: The standard to use on the final output
/// - Returns: A copy of `self` with the standard set
public func separator(_ separator: Separator) -> Self {
.init(standard, separator: separator)
}
}
}
extension ISBN.FormatStyle: Foundation.FormatStyle {
/// Returns a textual representation of the `ISBN` value passed in.
/// - Parameter value: A `ISBN` value
/// - Returns: The textual representation of the value, using the style's `standard`.
public func format(_ value: ISBN) -> String {
let parts = [
value.prefix,
value.registrationGroup,
value.registrant,
value.publication,
value.checkDigit,
]
switch standard {
case .isbn13:
return parts.joined(separator: separator.rawValue)
case .isbn10:
// ISBN-10 is missing the "prefix" portion of the number.
return parts.dropFirst().joined(separator: separator.rawValue)
}
}
}
// MARK: Convenience methods to access the formatted value
public extension ISBN {
/// Converts `self` to its textual representation.
/// - Returns: String
func formatted() -> String {
Self.FormatStyle().format(self)
}
/// Converts `self` to another representation.
/// - Parameter style: The format for formatting `self`
/// - Returns: A representations of `self` using the given `style`. The type of the return is determined by the FormatStyle.FormatOutput
func formatted<F: Foundation.FormatStyle>(_ style: F) -> F.FormatOutput where F.FormatInput == ISBN {
style.format(self)
}
}
// MARK: Convenience FormatStyle extensions to ease access
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public extension FormatStyle where Self == ISBN.FormatStyle {
static var isbn13: Self { .init(.isbn13, separator: .hyphen) }
static var isbn10: Self { .init(.isbn10, separator: .hyphen) }
static func isbn(
standard: ISBN.FormatStyle.Standard = .isbn13,
separator: ISBN.FormatStyle.Separator = .hyphen
) -> Self {
.init(standard, separator: separator)
}
}
// MARK: - Debug Methods on ISBN
extension ISBN: CustomDebugStringConvertible {
public var debugDescription: String {
"ISBN: \(formatted())"
}
}
import Foundation
// We need to create a new AttributedScope to contain our new attributes.
public extension AttributeScopes {
/// Represents the parts of an ISBN which we will be adding attributes to.
enum ISBNPart: Hashable {
case prefix
case registrationGroup
case registrant
case publication
case checkDigit
case separator
}
// Define our new AttributeScope
struct ISBNAttributes: AttributeScope {
// Our property value to access it.
public let isbnPart: ISBNAttributeKey
}
// We follow the AttributeStringKey protocol to define our new attribute.
enum ISBNAttributeKey: AttributedStringKey {
public typealias Value = ISBNPart
public static let name = "isbnPart"
}
// This extends AttributeScope to allow us to access our new ISBNPart type quickly.
var isbnPart: ISBNPart.Type { ISBNPart.self }
}
// We extend AttributeDynamicLookup to know about our custom type.
public extension AttributeDynamicLookup {
subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.ISBNAttributes, T>) -> T {
self[T.self]
}
}
// MARK: - AttributedString output FormatStyle
public extension ISBN {
/// An ISBN FormatStyle for outputting AttributedString values.
struct AttributedStringFormatStyle: Codable, Foundation.FormatStyle {
private let standard: ISBN.FormatStyle.Standard
private let separator: ISBN.FormatStyle.Separator
/// Initialize an ISBN FormatStyle with the given Standard
/// - Parameter standard: Standard (required)
public init(standard: ISBN.FormatStyle.Standard, separator: ISBN.FormatStyle.Separator) {
self.standard = standard
self.separator = separator
}
// The format method required by the FormatStyle protocol.
public func format(_ value: ISBN) -> AttributedString {
// Creates AttributedString representations of each part of the ISBN
var prefix = AttributedString(value.prefix)
var group = AttributedString(value.registrationGroup)
var registrant = AttributedString(value.registrant)
var publication = AttributedString(value.publication)
var checkDigit = AttributedString(value.checkDigit)
// Assigns our custom attribute scope attribute to each part.
prefix.isbnPart = .prefix
group.isbnPart = .registrationGroup
registrant.isbnPart = .registrant
publication.isbnPart = .publication
checkDigit.isbnPart = .checkDigit
// Collect all parts in an array to allow for simple AttributedString concatenation using reduce
let parts = [
prefix,
group,
registrant,
publication,
checkDigit,
]
// Create the final AttributedString by using the reduce method. We define the
switch standard {
case .isbn13 where separator == .none:
// Merge all parts into one string.
return parts.reduce(AttributedString(), +)
case .isbn13:
// Define the delimiter
var separator = AttributedString(separator.rawValue)
separator.isbnPart = .separator
// Starting with the .prefix, use reduce to build the final AttributedString.
return parts.dropFirst().reduce(prefix) { $0 + separator + $1 }
case .isbn10 where separator == .none:
// Drop the prefix, merge all parts.
return parts.dropFirst().reduce(group, +)
case .isbn10:
// Define the delimiter
var separator = AttributedString(separator.rawValue)
separator.isbnPart = .separator
// Drop the first two elements (prefix and group), then build the final AttributedString
return parts.dropFirst(2).reduce(group) { $0 + separator + $1 }
}
}
}
}
// MARK: AttributedStringFormatStyle convenience accessors
// Add our new attributed method chain to our format style.
public extension ISBN.FormatStyle {
var attributed: ISBN.AttributedStringFormatStyle {
.init(standard: standard, separator: separator)
}
}
import Foundation
// MARK: Add Validation to ISBN
public extension ISBN {
// Define our validation errors
enum ValidationError: Error {
case emptyInput
case noGroupsPresent
case invalidStringLength
case invalidCharacters
case checksumFailed
}
// Define our valid character set. We avoid using CharacterSet.decimalDigit since that includes
// all unicode characters which represents digits. ISBN values only use the Arabic numerals,
// hyphens, or spaces.
static let validCharacterSet = CharacterSet(charactersIn: "0123456789").union(validSeparatorsSet)
// Define our valid separators.
static let validSeparatorsSet = CharacterSet(charactersIn: "- ")
// Define the "Bookland" prefix (https://en.wikipedia.org/wiki/Bookland) to convert ISBN-10 values to ISBN-13
static let booklandPrefix = "978"
/// Returns a validated, 13 digit ISBN string.
/// https://en.wikipedia.org/wiki/ISBN#ISBN-13_check_digit_calculation
/// - Parameter value: A string representation of an ISBN
/// - Returns: String, the valid String that passed the check.
static func validate(_ candidate: String?) throws -> String {
// Unwrap the value passed in.
guard let candidate = candidate else { throw ValidationError.emptyInput }
// Validate that we have spacers present, otherwise we're not going to be able to parse out
// any ISBN values
guard candidate.rangeOfCharacter(from: Self.validSeparatorsSet) != nil else {
throw ValidationError.noGroupsPresent
}
// Trim any leading and trailing whitespace and newlines.
// Newlines will fail on the next check.
let trimmedString = candidate.trimmingCharacters(in: .whitespaces)
// Check for the existence of any invalid characters.
// We invert validCharacterSet to represent every other character in unicode than what is valid.
// If rangeOfCharacter returns a value, we know that those characters exist (and therefore fails)
guard trimmedString.rangeOfCharacter(from: Self.validCharacterSet.inverted) == nil else {
// So we throw the appropriate error
throw ValidationError.invalidCharacters
}
// Convert any ISBN-10 values into ISBN13 values by adding
// the "Bookland" prefix (https://en.wikipedia.org/wiki/Bookland)
let isbn13String = trimmedString.count == 10 ? Self.booklandPrefix + trimmedString : trimmedString
// Run the ISBN 13 checksum calculation
// https://en.wikipedia.org/wiki/ISBN#ISBN-13_check_digit_calculation
// Use the reduce method to run the checksum, starting with 0
// We enumerate the string because we need the position (it's offset) for each character, as
// well as the number itself.
// Start by removing all of the hyphens
let isbnString = isbn13String.components(separatedBy: .decimalDigits.inverted).joined()
// Verify that we have either 10 or 13 characters at this point.
guard [10, 13].contains(isbnString.count) else {
throw ValidationError.invalidStringLength
}
// First, we take the sum of the number. Multiplying each digit by either 1 or 3.
let sum = isbnString.enumerated().reduce(0) { partialResult, character in
// Safely convert the character into an integer.
guard let number = character.element.wholeNumberValue else {
return partialResult
}
// We alternate multiplying each character by 1 or 3
let multiplier = character.offset % 2 == 0 ? 1 : 3
// We then multiply the number by the multiplier, and add it to the previous result
return partialResult + (number * multiplier)
}
// We then make sure that the number is cleanly divisible by 10 by using the modulo function.
guard sum % 10 == 0 else {
throw ValidationError.checksumFailed
}
// Success. Return the original ISBN-10 or ISBN-13 string
return trimmedString
}
}
public extension ISBN.FormatStyle {
enum DecodingError: Error {
case invalidInput
}
struct ParseStrategy: Foundation.ParseStrategy {
public init() {}
public func parse(_ value: String) throws -> ISBN {
// Trim the input string any leading or trailing whitespaces
let trimmedValue = value.trimmingCharacters(in: .whitespaces)
// Attempt to validate our trimmed string
let validISBN = try ISBN.validate(trimmedValue)
// Create an array of strings based on the separator used.
let components = validISBN.components(separatedBy: ISBN.validSeparatorsSet)
// Having 4 components means that we were given an ISBN-10 number.
// Therefore we need to convert it.
let finalComponents = components.count == 4 ? [ISBN.booklandPrefix] + components : components
// Since we're going to use subscripts to access each value in the array, it's a good
// idea to verify that all values are present to avoid crashing.
guard finalComponents.count == 5 else {
throw DecodingError.invalidInput
}
// Build the final ISBN from the component parts.
return ISBN(
prefix: finalComponents[0],
registrationGroup: finalComponents[1],
registrant: finalComponents[2],
publication: finalComponents[3],
checkDigit: finalComponents[4]
)
}
}
}
// MARK: ParseableFormatStyle conformance on ISBN.FormatStyle
extension ISBN.FormatStyle: ParseableFormatStyle {
public var parseStrategy: ISBN.FormatStyle.ParseStrategy {
.init()
}
}
// MARK: Convenience members on ISBN to simplify access to the ParseStrategy
public extension ISBN {
init(_ string: String) throws {
self = try ISBN.FormatStyle().parseStrategy.parse(string)
}
init<T, Value>(_ value: Value, standard: T) throws where T: ParseStrategy, Value: StringProtocol, T.ParseInput == String, T.ParseOutput == ISBN {
self = try standard.parse(value.description)
}
}
// MARK: Extend ParseableFormatStyle to simplify access to the format style
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public extension ParseableFormatStyle where Self == ISBN.FormatStyle {
static var isbn: Self { .init() }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment