Question

I have a NSAttributedString object as a property of a custom object. I need to save this custom object to the disk in JSON format. Later I need to send this JSON data across the network to a Java Server.
I can't use the -(NSString) string method of the NSSAttributedString object because I need to be able to reconstruct the attributed string off the disk and on the server.

Was it helpful?

Solution

NSAttributedString has two properties:

  • the string
  • an array of attribute "runs"

Each "run" has:

  • an integer range that it applies to
  • a dictionary of key/value attributes

It would be very easy to represent that as JSON, using enumerateAttributesInRange:options:usingBlock:.

Something like:

{
  "string" : "Hello World",
  "runs" : [
    {
      "range" : [0,3],
      "attributes" : {
        "font" : {
          "name" : "Arial",
          "size" : 12
        }
      }
    },
    {
      "range" : [3,6],
      "attributes" : {
        "font" : {
          "name" : "Arial",
          "size" : 12
        },
        "color" : [255,0,0]
      }
    },
    {
      "range" : [9,2],
      "attributes" : {
        "font" : {
          "name" : "Arial",
          "size" : 12
        }
      }
    }
  ]
}

EDIT: here's an example implementation:

// create a basic attributed string
NSMutableAttributedString *attStr = [[NSMutableAttributedString alloc] initWithString:@"Hello World" attributes:@{NSFontAttributeName: [NSFont fontWithName:@"Arial" size:12]}];
[attStr addAttribute:NSForegroundColorAttributeName value:[NSColor redColor] range:NSMakeRange(3, 6)];

// build array of attribute runs
NSMutableArray *attributeRuns = [NSMutableArray array];
[attStr enumerateAttributesInRange:NSMakeRange(0, attStr.length) options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) {
  NSArray *rangeArray = @[[NSNumber numberWithUnsignedInteger:range.location],
                          [NSNumber numberWithUnsignedInteger:range.length]];

  NSMutableDictionary *runAttributes = [NSMutableDictionary dictionary];
  [attrs enumerateKeysAndObjectsUsingBlock:^(id attributeName, id attributeValue, BOOL *stop) {

    if ([attributeName isEqual:NSFontAttributeName]) { // convert font values into a dictionary with the name and size
      attributeName = @"font";
      attributeValue = @{@"name": [(NSFont *)attributeValue displayName],
                         @"size": [NSNumber numberWithFloat:[(NSFont *)attributeValue pointSize]]};

    } else if ([attributeName isEqualToString:NSForegroundColorAttributeName]) { // convert foreground colour values into an array with red/green/blue as a number from 0 to 255
      attributeName = @"color";
      attributeValue = @[[NSNumber numberWithInteger:([(NSColor *)attributeValue redComponent] * 255)],
                         [NSNumber numberWithInteger:([(NSColor *)attributeValue greenComponent] * 255)],
                         [NSNumber numberWithInteger:([(NSColor *)attributeValue blueComponent] * 255)]];

    } else { // skip unknown attributes
      NSLog(@"skipping unknown attribute %@", attributeName);
      return;
    }


    [runAttributes setObject:attributeValue forKey:attributeName];
  }];

  // save the attributes (if there are any)
  if (runAttributes.count == 0)
    return;

  [attributeRuns addObject:@{@"range": rangeArray,
                             @"attributes": runAttributes}];
}];

// build JSON output
NSDictionary *jsonOutput = @{@"string": attStr.string,
                             @"runs": attributeRuns};
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonOutput options:NSJSONWritingPrettyPrinted error:NULL];

NSLog(@"%@", [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]);
exit(0);

OTHER TIPS

You could try starting with RTFFromRange:

From the documentation: For information about the OS X methods supporting RTF, ..., see NSAttributedString Application Kit Additions Reference.

RTF should be self contained. RTFFromRange: returns NSData; I would think its probably character data in some encoding so should be easy to convert to NSString.

(Sorry, just read that method is MacOS X only).

You could use this simple code snippet to convert NSAttributedString to XML without actually parsing NSAttributedString. This can be a human-readable alternative to JSON if you can afford verbose text output.

It can be also used for decoding back to NSAttributedString.

    let data = NSMutableData()

    let archiver = NSKeyedArchiver(forWritingWithMutableData: data)
    archiver.outputFormat = .XMLFormat_v1_0
    textView.attributedText.encodeWithCoder(archiver)
    archiver.finishEncoding()

    let textAsString = NSString(data: data, encoding: NSUTF8StringEncoding)'

Swift 5 version of @AbhiBeckert's answer:

let attributedString: NSAttributedString! // Input -> NSAttributedString
let RUN_ATTRIBUTES_ARRAY: NSMutableArray = []

attributedString!.enumerateAttributes(in: .init(location: 0, length: attributedString!.length), options: [], using: { attributedDictionary, range, stop in // Retrieve all of attributed string's attributes
    let runAttributes: NSMutableDictionary = NSMutableDictionary()
    
    // Convert each attribute's value to a JSON formattable type
    for attribute in attributedDictionary {
        if (attribute.key == .font) {
            let values: NSDictionary = [
                "name": (attribute.value as! NSFont).displayName!,
                "size": (attribute.value as! NSFont).pointSize
            ]
            
            runAttributes.setValue(values, forKey: "font") // Apply the value with its key to a mutable dictionary
        }
    }
    
    // Add the previously accumulated values to a mutable array along with the corresponding range
    RUN_ATTRIBUTES_ARRAY.add([
        "range": [range.lowerBound, range.upperBound],
        "attributes": runAttributes
    ])
})

// Create a dictionary with the attributes and the text value
let dictionary: NSDictionary = [
    "string": attributedString!.string,
    "runs": RUN_ATTRIBUTES_ARRAY
]

// Convert the dictionary to JSON
do {    // capture potential throw below
    let jsonData: Data = try JSONSerialization.data(withJSONObject: dictionary, options: [.prettyPrinted, .sortedKeys])
    print(jsonData) // Output -> JSON
} catch {
    print("Error converting dictionary to JSON")
}

The following code converts JSON back into an attributed string:

private func convertAttributesFromJSONToDictionary(_ attributes: Any) -> [NSAttributedString.Key: Any]? {
    if let attrValue: [String: [String: Any]] = (attributes as? [String: [String: Any]]) {
        /*
         attrValue = [
             "font" : {
               "name" : "Helvetica",
               "size" : 12
             },
             "color" : [255,0,0]
         ]
         */
        var attrDict: [NSAttributedString.Key: Any] = [:]
        
        for (key, value) in attrValue { // Loop through each attribute
            if (key == "font") {
                // Retrieve all attribute values
                var name: String = "Helvetica"
                var size: CGFloat = 12
                
                for (fontKey, fontValue) in value {
                    if (fontKey == "name") {
                        name = (fontValue as! String)
                    } else if (fontKey == "size") {
                        size = (fontValue as! CGFloat)
                    }
                }
                
                if let font: NSFont = NSFont(name: name, size: size) {
                    // Add retrieved values to a dictionary
                    attrDict.updateValue(font, forKey: .font)
                } else {
                    print("Unable to implement font attribute")
                }
            }
        }
        
        return attrDict // Return filled dictionary
    }
    
    return nil
}

public func convertJSONToAttributedString() {
    var dictionary: [String: Any]! // Input -> JSON
    
    // Create attributed string with text string
    guard let string: String = (dictionary["string"] as? String) else {
        print("Incorrect json structure {string}")
    }
    
    let attrString: NSMutableAttributedString = NSMutableAttributedString(string: string)
    
    if let runsDict: [[String: Any]] = (dictionary["runs"] as? [[String: Any]]) { // Check for 'runs' key in JSON data
        /*
         runsDict = [
             {
               "attributes" : {Any},
               "range" : [Int]
             }, {
               "attributes" : {Any},
               "range" : [Int]
             }
         ]
         */
        for run in runsDict { // Loop through each attributes and range section
            var attributes: [NSAttributedString.Key: Any] = [:]
            var range: NSRange?
            
            for (key, value) in run {
                // Retrieve all attributes and the range
                if (key == "attributes") {
                    if let attrDict: [NSAttributedString.Key: Any] = convertAttributesFromJSONToDictionary(value) {
                        attributes = attrDict
                    }
                } else if (key == "range") {
                    if let rangeValue: [Int] = (value as? [Int]) {
                        range = NSRange(location: rangeValue[0], length: (rangeValue[1] - rangeValue[0]))
                    }
                }
                
                // Add retrieved attributes and range to the attributed string
                if ((key == "attributes" || key == "range") && range != nil) {
                    attrString.addAttributes(attributes, range: range!)
                }
            }
        }
    } else {
        print("Incorrect json structure {runs}")
    }

    print(attrString) // Output -> NSAttributedString
}
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top