NSAttributedString, change the font overall BUT keep all other attributes?

SwiftNsattributedstring

Swift Problem Overview


Say I have an NSMutableAttributedString .

The string has a varied mix of formatting throughout:

Here is an example:

> This string is hell to change in iOS, it really sucks.

However, the font per se is not the font you want.

I want to:

for each and every character, change that character to a specific font (say, Avenir)

BUT,

for each and every character, keep the mix of other attributions (bold, italic, colors, etc etc) which was previously in place on that character.

How the hell do you do this?


Note:

if you trivially add an attribute "Avenir" over the whole range: it simply deletes all the other attribute ranges, you lose all formatting. Unfortunately, attributes are not, in fact "additive".

Swift Solutions


Solution 1 - Swift

Since rmaddy's answer did not work for me (f.fontDescriptor.withFace(font.fontName) does not keep traits like bold), here is an updated Swift 4 version that also includes color updating:

extension NSMutableAttributedString {
    func setFontFace(font: UIFont, color: UIColor? = nil) {
        beginEditing()
        self.enumerateAttribute(
            .font, 
            in: NSRange(location: 0, length: self.length)
        ) { (value, range, stop) in

            if let f = value as? UIFont, 
              let newFontDescriptor = f.fontDescriptor
                .withFamily(font.familyName)
                .withSymbolicTraits(f.fontDescriptor.symbolicTraits) {

                let newFont = UIFont(
                    descriptor: newFontDescriptor, 
                    size: font.pointSize
                )
                removeAttribute(.font, range: range)
                addAttribute(.font, value: newFont, range: range)
                if let color = color {
                    removeAttribute(
                        .foregroundColor, 
                        range: range
                    )
                    addAttribute(
                        .foregroundColor, 
                        value: color, 
                        range: range
                    )
                }
            }
        }
        endEditing()
    }
}

Notes

The problem with f.fontDescriptor.withFace(font.fontName) is that it removes symbolic traits like italic, bold or compressed, since it will for some reason override those with default traits of that font face. Why this is so totally eludes me, it might even be an oversight on Apple's part; or it's "not a bug, but a feature", because we get the new font's traits for free.

So what we have to do is create a font descriptor that has the symbolic traits from the original font's font descriptor: .withSymbolicTraits(f.fontDescriptor.symbolicTraits). Props to rmaddy for the initial code on which I iterated.

I've already shipped this in a production app where we parse a HTML string via NSAttributedString.DocumentType.html and then change the font and color via the extension above. No problems so far.

Solution 2 - Swift

Here is a much simpler implementation that keeps all attributes in place, including all font attributes except it allows you to change the font face.

Note that this only makes use of the font face (name) of the passed in font. The size is kept from the existing font. If you want to also change all of the existing font sizes to the new size, change f.pointSize to font.pointSize.

extension NSMutableAttributedString {
    func replaceFont(with font: UIFont) {
        beginEditing()
        self.enumerateAttribute(.font, in: NSRange(location: 0, length: self.length)) { (value, range, stop) in
            if let f = value as? UIFont {
                let ufd = f.fontDescriptor.withFamily(font.familyName).withSymbolicTraits(f.fontDescriptor.symbolicTraits)!
                let newFont = UIFont(descriptor: ufd, size: f.pointSize)
                removeAttribute(.font, range: range)
                addAttribute(.font, value: newFont, range: range)
            }
        }
        endEditing()
    }
}

And to use it:

let someMutableAttributedString = ... // some attributed string with some font face you want to change
someMutableAttributedString.replaceFont(with: UIFont.systemFont(ofSize: 12))

Solution 3 - Swift

my two cents for OSX/AppKit (changing

extension NSAttributedString {
// replacing font to all:
func setFont(_ font: NSFont, range: NSRange? = nil)-> NSAttributedString {
    let mas = NSMutableAttributedString(attributedString: self)
    let range = range ?? NSMakeRange(0, self.length)
    mas.addAttributes([.font: font], range: range)
    return NSAttributedString(attributedString: mas)
}


// keeping foot, but changing size:
func setFont(size: CGFloat, range: NSRange? = nil)-> NSAttributedString {
    let mas = NSMutableAttributedString(attributedString: self)
    let range = range ?? NSMakeRange(0, self.length)
    
    
    mas.enumerateAttribute(.font, in: range) { value, range, stop in
        if let font = value as? NSFont {
            let name = font.fontName
            let newFont = NSFont(name: name, size: size)
            mas.addAttributes([.font: newFont!], range: range)
        }
    }
    return NSAttributedString(attributedString: mas)
    
}

Solution 4 - Swift

Important -

rmaddy has invented an entirely new technique for this annoying problem in iOS.

The answer by manmal is the final perfected version.

Purely for the historical record here is roughly how you'd go about doing it the old days...


// carefully convert to "our" font - "re-doing" any other formatting.
// change each section BY HAND.  total PITA.

func fixFontsInAttributedStringForUseInApp() {
	
	cachedAttributedString?.beginEditing()
	
	let rangeAll = NSRange(location: 0, length: cachedAttributedString!.length)
	
	var boldRanges: [NSRange] = []
	var italicRanges: [NSRange] = []
	
	var boldANDItalicRanges: [NSRange] = [] // WTF right ?!
	
	cachedAttributedString?.enumerateAttribute(
			NSFontAttributeName,
			in: rangeAll,
			options: .longestEffectiveRangeNotRequired)
				{ value, range, stop in
	
				if let font = value as? UIFont {
					
					let bb: Bool = font.fontDescriptor.symbolicTraits.contains(.traitBold)
					let ii: Bool = font.fontDescriptor.symbolicTraits.contains(.traitItalic)
					
					// you have to carefully handle the "both" case.........
					
					if bb && ii {
						
						boldANDItalicRanges.append(range)
					}
					
					if bb && !ii {
						
						boldRanges.append(range)
					}
					
					if ii && !bb {
						
						italicRanges.append(range)
					}
				}
			}
	
	cachedAttributedString!.setAttributes([NSFontAttributeName: font_f], range: rangeAll)
	
	for r in boldANDItalicRanges {
		cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fBOTH, range: r)
	}
	
	for r in boldRanges {
		cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fb, range: r)
	}
	
	for r in italicRanges {
		cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fi, range: r)
	}

	cachedAttributedString?.endEditing()
}

.


Footnote. Just for clarity on a related point. This sort of thing inevitably starts as a HTML string. Here's a note on how to convert a string that is html to an NSattributedString .... you will end up with nice attribute ranges (italic, bold etc) BUT the fonts will be fonts you don't want.

fileprivate extension String {
    func htmlAttributedString() -> NSAttributedString? {
        guard let data = self.data(using: String.Encoding.utf16, allowLossyConversion: false) else { return nil }
        guard let html = try? NSMutableAttributedString(
            data: data,
            options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
            documentAttributes: nil) else { return nil }
        return html
    }
}  

.

Even that part of the job is non-trivial, it takes some time to process. In practice you have to background it to avoid flicker.

Solution 5 - Swift

Obj-C version of @manmal's answer

@implementation NSMutableAttributedString (Additions)

- (void)setFontFaceWithFont:(UIFont *)font color:(UIColor *)color {
    [self beginEditing];
    [self enumerateAttribute:NSFontAttributeName
                     inRange:NSMakeRange(0, self.length)
                     options:0
                  usingBlock:^(id  _Nullable value, NSRange range, BOOL * _Nonnull stop) {
                      UIFont *oldFont = (UIFont *)value;
                      UIFontDescriptor *newFontDescriptor = [[oldFont.fontDescriptor fontDescriptorWithFamily:font.familyName] fontDescriptorWithSymbolicTraits:oldFont.fontDescriptor.symbolicTraits];
                      UIFont *newFont = [UIFont fontWithDescriptor:newFontDescriptor size:font.pointSize];
                      if (newFont) {
                          [self removeAttribute:NSFontAttributeName range:range];
                          [self addAttribute:NSFontAttributeName value:newFont range:range];
                      }
                  
                      if (color) {
                          [self removeAttribute:NSForegroundColorAttributeName range:range];
                          [self addAttribute:NSForegroundColorAttributeName value:newFont range:range];
                      }
                  }];
    [self endEditing];
}

@end

Solution 6 - Swift

Would it be valid to let a UITextField do the work?

Like this, given attributedString and newfont:

let textField = UITextField()
textField.attributedText = attributedString
textField.font = newFont
let resultAttributedString = textField.attributedText

Sorry, I was wrong, it keeps the "Character Attributes" like NSForegroundColorAttributeName, e.g. the colour, but not the UIFontDescriptorSymbolicTraits, which describe bold, italic, condensed, etc.

Those belong to the font and not the "Character Attributes". So if you change the font, you are changing the traits as well. Sorry, but my proposed solution does not work. The target font needs to have all traits available as the original font for this to work.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionFattieView Question on Stackoverflow
Solution 1 - SwiftmanmalView Answer on Stackoverflow
Solution 2 - SwiftrmaddyView Answer on Stackoverflow
Solution 3 - SwiftingcontiView Answer on Stackoverflow
Solution 4 - SwiftFattieView Answer on Stackoverflow
Solution 5 - SwiftlandonandreyView Answer on Stackoverflow
Solution 6 - Swiftuser2782993View Answer on Stackoverflow