Apple rich text fundamentals 👨🏻‍🎨

🤿
A deep dive into NSAttributedString.
🧑🏻
by Mihhail
17 min read

It looks like there’s a pattern emerging with these articles. The first one was mostly about the app. The second one is mostly about the tech. And this one zooms even further into a particular piece of the tech. I wonder what’s the endgame here… 🤔

Anyway… After a general introduction to NSAttributedString I thought I’d give a more thorough overview of the styling attributes, plus of the things you can do with the attributed string besides using it inside a TextView.

Basics

Plain text is just text. The app that displays the text is in charge of its looks. Every paragraph is uniform — it has the same font, text size, color, and so on. You can change the visual look of the whole document, but not of individual words, phrases, or paragraphs. Think Sublime or BBEdit.

Rich text is a common term used to describe the text that can be styled by the user. In most cases, the flow is such that the user first selects a piece of text and then presses a button to toggle the desired style — make it bold, highlighted, bigger, etc. Think TextEdit or Microsoft Word.

It’s easy to think about rich text solely in the context of text editing applications such as TextEdit. After all — that’s how most people interact with it. However, the applications are simply means of manipulating it. Rich text is first and foremost a data structure with system-level support. It can be edited through a UI, serialized to a file, transferred to another application through copy-paste, and converted to other representations using built-in system APIs.

The programmatic data structure that’s responsible for handling rich text in AppKit and UIKit is called NSAttributedString. It consists of two parts:

  1. A regular text string.
  2. Key-value pairs of attributes attached to ranges of text within the string.
a
0
t
1
t
2
r
3
i
4
b
5
u
6
t
7
e
8
d
9
 
10
s
11
t
12
r
13
i
14
n
15
g
16
KeyValueRange
   
   
   

The key in the pair is a string that identifies the type of the attribute. The strings of framework-defined attributes are stored in constants suffixed with AttributeName — for example, NSFontAttributeName or NSForegroundColorAttributeName.

The value is either a number or a data structure describing the properties of the attribute in the specified range — represented by the NSRange structure.

NSRange is a data structure consisting of two numbers:

  1. location — the index of the character the range starts on.
  2. length — the number of characters the range spans for.

The API of NSAttributedString can be divided into four chunks:

  1. Reading

    • Query the attribute at the specified location.
    • Traverse the ranges of attributes within the specified range.
  2. Updating

    • Add the attribute to the specified range.
    • Remove the attribute from the specified range.
  3. Converting

    • Convert either the whole string or a piece of it to other document formats.
    • Create an attributed string from NSData or NSFileWrapper representing a format.
  4. Drawing

    • Measure how much screen space is needed to draw the string.
    • Draw the string onto a bitmap canvas.

And that’s all there is to say about this data structure. My previous article has a more detailed walkthrough with code and illustrations, in case you’re interested.

For this article though, I wanted to make things a bit more interactive. The widget below lets you play with the attributed string to see what attributes are applied to what ranges within the string. Select some text on the left to inspect the attributes on the right.

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

 

Next, let’s learn about each group of attributes in more detail.

Colors

There are a total of five attributes that influence the colors inside a string of rich text.

NSForegroundColorAttributeName.m
[string addAttribute:NSForegroundColorAttributeName
               value:NSColor.textColor
               range:NSMakeRange(0, 22)];
The color of the text.
NSBackgroundColorAttributeName.m
[string addAttribute:NSBackgroundColorAttributeName
               value:NSColor.limeColor
               range:NSMakeRange(17, 17)];
The color of the background behind the text.
NSStrokeColorAttributeName.m
[string addAttribute:NSStrokeColorAttributeName
               value:NSColor.pinkColor
               range:NSMakeRange(17, 19)];
The color of the outline surrounding the text.
NSUnderlineColorAttributeName.m
[string addAttribute:NSUnderlineColorAttributeName
               value:NSColor.yellowColor
               range:NSMakeRange(17, 10)];
The color of the line below the text.
NSStrikeColorAttributeName.m
[string addAttribute:NSStrikeColorAttributeName
               value:NSColor.purpleColor
               range:NSMakeRange(17, 18)];
The color of the line going through the text.

The value of these attributes is an object of type NSColor/UIColor. Both of those classes are nothing more than a fancy way to store 4 floating point numbers: red, green, blue, and alpha (transparency). NSColor is a bit more sophisticated as it also allows explicit control over the color space through NSColorSpace.

Colors.m
// AppKit
// RGBA(200, 50, 100, 0.5)
[NSColor colorWithRed:200.0 / 255.0
                green:50.0 / 255.0
                 blue:100.0 / 255.0
                alpha:0.5];

[NSColor colorWithSRGBRed:200.0 / 255.0
                    green:50.0 / 255.0
                     blue:100.0 / 255.0
                    alpha:0.5];

// HSBA converts to RGBA on creation
[NSColor colorWithColorSpace:NSColorSpace.displayP3ColorSpace
                         hue:0.4
                  saturation:0.5
                  brightness:0.6
                       alpha:0.7];

// UIKit
// RGBA(200, 50, 100, 0.5)
[UIColor colorWithRed:200.0 / 255.0
                green:50.0 / 255.0
                 blue:100.0 / 255.0
                alpha:0.5];

[UIColor colorWithDisplayP3Red:0.4 green:0.5 blue:0.6 alpha:0.7];

As an exception to the rule, there is a special type of Color that, instead of having 4 color components, contains an NSImage/UIImage object. Those colors draw with a pattern instead of a solid color.

Now if you ask me, I’d say that’s somewhat lazy object-oriented design. How can an image be a color? My guess is it simply made sense with the way the drawing APIs are designed, even though semantically it looks off.

Pattern.m
[NSColor colorWithPatternImage:[NSImage imageNamed:@"pattern"]]; [UIColor colorWithPatternImage:[UIImage imageNamed:@"pattern"]];

The important part is that pattern colors can be used to apply repeating patterns onto the background and even onto text.

The last interesting tidbit about colors is that NSColor (but not UIColor) has a dedicated method for blending colors.

Blending.m
// purple
[NSColor.redColor blendedColorWithFraction:0.5
                                   ofColor:NSColor.blueColor];

Fonts

Fonts are defined through the NSFont/UIFont object consisting mainly of two properties:

  1. Name
  2. Size
NSFontAttributeName.m
[string addAttribute:NSFontAttributeName
               value:[NSFont fontWithName:@"SF Pro"
                                     size:16.0] // points (pt)
               range:NSMakeRange(0, 27)];
Sets the font and its size.

In addition to the font attribute itself, you have four other attributes that adjust typography-related properties.

NSKernAttributeName.m
[string addAttribute:NSKernAttributeName
               value:@0.0 // points (pt)
               range:NSMakeRange(25, 14)];
Increases or reduces the letter spacing.
NSBaselineOffsetAttributeName.m
[string addAttribute:NSBaselineOffsetAttributeName
               value:@0.0 // points (pt)
               range:NSMakeRange(17, 8)];
Raises or lowers the text within the line.
NSSuperscriptAttributeName.m
// does not affect the font size
[string addAttribute:NSSuperscriptAttributeName
               value:@0 // level
               range:NSMakeRange(0, 0)];
Applies the superscript or subscript offset.
NSLigatureAttributeName.m
// 0 → No ligatures
// 1 → Default ligatures
// 2 → All ligatures (only in AppKit)
[string addAttribute:NSLigatureAttributeName
               value:@0
               range:NSMakeRange(42, 40)];
Enables or disables ligatures like these: ff ffi fft ft fi tt tf df dt ff kf kt rf.

A common action in rich text editors is adding or removing font styles to or from the text. To pull this off, we need a way to convert a font to another font with or without the specified style. Luckily, AppKit has a useful class called NSFontManager just for this purpose.

NSFontManager.h
- (NSFont *)convertFont:(NSFont *)font
            toHaveTrait:(NSFontTraitMask)trait;
- (NSFont *)convertFont:(NSFont *)font
         toNotHaveTrait:(NSFontTraitMask)trait;

Here’s how one would use it:

ConvertingTraits.m
NSFont *font = [NSFont fontWithName:@"Arial Bold Italic" size:12.0];
// Arial Bold Italic 12.0
NSLog(@"%@ %f", font.displayName, font.pointSize);

NSFont *convertedFont = [NSFontManager.sharedFontManager
  convertFont:font toNotHaveTrait:NSBoldFontMask | NSItalicFontMask];
// Arial 12.0
NSLog(@"%@ %f", convertedFont.displayName, convertedFont.pointSize);

Sadly, this handy class does not exist in UIKit. That said, both NSFont and UIFont have a lower-level data structure called FontDescriptor that can be used to achieve the same effect. It views every font as a set of traits and attributes allowing you to query a font that matches the desired specification. Here’s how to accomplish the above using the font descriptor:

ConvertingTraits.m
NSFont *font = [NSFont fontWithName:@"Arial Bold Italic" size:12.0];
// Arial Bold Italic 12.0
NSLog(@"%@ %f", font.displayName, font.pointSize);

NSFont *convertedFont = [NSFont fontWithDescriptor:
  [NSFontDescriptor fontDescriptorWithFontAttributes:@{
    NSFontNameAttribute: font.familyName,
    NSFontTraitsAttribute: @{
      NSFontWeightTrait: @0.0,
      NSFontSlantTrait: @0.0
    }
  }] size:font.pointSize];
// Arial 12.0
NSLog(@"%@ %f", convertedFont.displayName, convertedFont.pointSize);

Another important detail about fonts is that not every font has a glyph for every possible character. To ensure that all characters remain visible, the fixAttributesInRange method can be called to (among other things) go over all font attributes and replace the fonts that cannot display the assigned characters with a fallback one that can. In NSTextStorage (the child class of NSAttributedString that TextView uses to store rich text) this method is called automatically after every change.

In case you want to override the fallback logic, there is an option to create a font with your own cascade list.

Fallback.m
NSFont *fontWithFallback = [NSFont fontWithDescriptor:
  [NSFontDescriptor fontDescriptorWithFontAttributes:@{
    NSFontNameAttribute: @"Courier",
    NSFontCascadeListAttribute: @[
      [NSFontDescriptor fontDescriptorWithFontAttributes:@{
        NSFontNameAttribute: @"Courier New"
      }]
    ]
  }] size:12.0];

Paragraph style

Paragraph style is in charge of the vast majority of text layout properties. The value of this attribute is an object of type NSParagraphStyle.

Each paragraph should have a single NSParagraphStyle applied to the whole range of the paragraph. If multiple different styles are mixed in a single paragraph, it will pick the one attached to the first character. The familiar fixAttributesInRange method can be called to ensure that a uniform paragraph style is applied to the full length of every paragraph.

NSParagraphStyle has a lot of properties. I’ve arranged them into several groups to make it more digestible.

Line

The first group is responsible for the properties of the lines within a paragraph.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
style.minimumLineHeight = 0.0;
style.maximumLineHeight = CGFLOAT_MAX;
style.lineHeightMultiple = 1.0;
style.lineSpacing = 0.0;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 23)];
Line Height 
First line.
 Line Spacing
Line Height 
Second line.
Min Line Height
Max Line Height
Line Height Multiple
Line Spacing

Paragraph

The second group controls the layout within a single paragraph and between neighboring paragraphs.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
style.firstLineHeadIndent = 0.0;
style.headIndent = 0.0;
style.tailIndent = 0.0;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 123)];
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
First Line Indent
Head Indent
Tail Indent
NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
style.paragraphSpacing = 0.0;
style.paragraphSpacingBefore = 0.0;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 126)];
This is the first paragraph with some filler text to make it longer.
 Spacing After
Spacing Before 
This is the second paragraph with even more filler text.
Spacing Before
Spacing After
NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
// NSTextAlignmentNatural: LTR → Left, RTL → Right
// NSTextAlignmentLeft
// NSTextAlignmentRight
// NSTextAlignmentJustified
style.alignment = NSTextAlignmentNatural;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 123)];
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

The last property that I’d bucket to this group is headerLevel. Its only purpose is to mark the paragraph as a heading when converting to or from HTML. More on conversions in the last chapter.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
style.headerLevel = 2; // <h2 />

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 24)];

Wrapping & clipping

lineBreakMode specifies what happens with the text when it reaches the end of the line or the end of allocated space (for single-line or fixed containers).

The most common options are to either wrap or clip the text.

The word wrap option has an extra setting called hyphenationFactor that adjusts the eagerness with which the layout engine splits the words between the lines.

Additionally, the usesDefaultHyphenation property can be enabled to automatically pick the optimal hyphenationFactor for a given paragraph. When this property is turned on, the hyphenationFactor can be queried to get the value picked by the system.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
// NSLineBreakByWordWrapping
// NSLineBreakByCharWrapping
// NSLineBreakByClipping
style.lineBreakMode = NSLineBreakByWordWrapping;
style.hyphenationFactor = 0.0; // 0.0 … 1.0
style.usesDefaultHyphenation = NO;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 123)];
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Mode
Hyphenation

Finally, lineBreakStrategy can be used to further tinker with the wrapping logic. That said, I have yet to figure out what do those strategies influence exactly. To my eye, nothing changes when switching between them. 🤔

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
// NSLineBreakStrategyNone
// NSLineBreakStrategyPushOut
// NSLineBreakStrategyHangulWordPriority
// NSLineBreakStrategyStandard
style.lineBreakStrategy = NSLineBreakStrategyNone;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 123)];

Truncation

Besides wrapping and clipping, lineBreakMode also offers options for truncation, which is the same as clipping, but with the cut-off point marked by an ellipsis.

For truncation, Apple developed a neat trick where the layout engine reduces the letter spacing before truncating to keep the text fully visible when it would have otherwise been cut off. tighteningFactorForTruncation property controls the aggressiveness of this effect. Same as with wrapping, allowsDefaultTighteningForTruncation can be enabled to let the system pick the optimal value.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
// NSLineBreakByTruncatingHead
// NSLineBreakByTruncatingTail
// NSLineBreakByTruncatingMiddle
style.lineBreakMode = NSLineBreakByTruncatingMiddle;
style.tighteningFactorForTruncation = 0.0 // 0.0 … 1.0;
style.allowsDefaultTighteningForTruncation = NO;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(17, 13)];
This text might be truncated.
Truncate
Tightening

Writing direction

The natural writing direction of most alphabetic languages is left-to-right. For languages such as Arabic and Hebrew, it is right-to-left. However, nothing stops you from overriding the default direction for a given paragraph.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
// NSWritingDirectionNatural → Unicode Bidi Algorithm rules P2 and P3
// NSWritingDirectionLeftToRight
// NSWritingDirectionRightToLeft
style.baseWritingDirection = NSWritingDirectionNatural;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 123)];
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

More advanced scenarios, such as mixing both directions within a single paragraph, are also possible with the NSWritingDirectionAttributeName. Quoting Apple’s documentation:

This attribute provides a means to override the default bidirectional text algorithm, equivalent to using the Unicode bidi control characters LRE, RLE, LRO, or RLO paired with PDF, but as a higher-level attribute. The NSWritingDirectionAttributeName constant is a character-level attribute that provides a higher-level alternative to the inclusion of explicit bidirectional control characters in text. It is the NSAttributedString equivalent of the HTML markup using bdo element with the dir attribute.
NSParagraphStyleAttributeName.m
// LRE
[string addAttribute:NSWritingDirectionAttributeName
               value:NSWritingDirectionLeftToRight |
                     NSWritingDirectionEmbedding
               range:NSMakeRange(23, 5)];

// RLE
[string addAttribute:NSWritingDirectionAttributeName
               value:NSWritingDirectionRightToLeft |
                     NSWritingDirectionEmbedding
               range:NSMakeRange(23, 5)];

// LRO
[string addAttribute:NSWritingDirectionAttributeName
               value:NSWritingDirectionLeftToRight |
                     NSWritingDirectionOverride
               range:NSMakeRange(23, 5)];

// RLO
[string addAttribute:NSWritingDirectionAttributeName
               value:NSWritingDirectionRightToLeft |
                     NSWritingDirectionOverride
               range:NSMakeRange(23, 5)];

Tab stops

Tab characters are rather special in text editing. Unlike spaces, their width varies depending on where they are in the text. The right edge of a tab always snaps to one of the evenly spaced vertical rulers. This makes them ideal for aligning text between paragraphs such as arranging it into columns.

In rich text, you can configure the location of every vertical ruler, individually for every paragraph. tapStops property gives fine-grained control over the initial set of snapping locations while defaultTabInterval spaces out the rest of them in even increments.

NSParagraphStyleAttributeName.m
NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
style.tabStops = @[
  [NSTextTab.alloc initWithTextAlignment:NSTextAlignmentNatural
                                location:32.0
                                 options:@{}],
  [NSTextTab.alloc initWithTextAlignment:NSTextAlignmentNatural
                                location:96.0
                                 options:@{}]
];
style.defaultTabInterval = 24.0;

[string addAttribute:NSParagraphStyleAttributeName
               value:style
               range:NSMakeRange(0, 29)];
Words
separated
with
tabs.
First Tab Stop
Second Tab Stop
Default Interval

Lists

Lists are special multi-paragraph constructs in rich text. They have a few distinct qualities:

  • Every list item has a leading marker.
  • Paragraphs are indented and aligned behind the marker.
  • Individual list items can have different levels of nesting.
  • Numbered lists are automatically numbered.
  • Pressing Enter inside a list creates a new list item.

Several conditions must be met for a paragraph to be recognized as a list item in Apple’s rich text system:

  1. It must start with a marker.
  2. The marker has to be surrounded by the tab characters.
  3. The textLists paragraph style property must be set describing the type of the list and the types of all parent lists (if any).
NSParagraphStyleAttributeName.m
NSTextList *bullet = [NSTextList.alloc
                        initWithMarkerFormat:NSTextListMarkerDisc
                                     options:kNilOptions];
NSString *bulletContent =
  [NSString stringWithFormat:@"\t%@\tBulleted Item\n",
    [bullet markerForItemNumber:0]];
NSMutableAttributedString *bulletItem =
  [NSMutableAttributedString.alloc initWithString:bulletContent];
NSMutableParagraphStyle *bulletStyle = NSMutableParagraphStyle.new;
bulletStyle.textLists = @[ bullet ];
[bulletItem addAttribute:NSParagraphStyleAttributeName
                   value:bulletStyle
                   range:NSMakeRange(0, bulletItem.length)];
[string appendAttributedString:bulletItem];

NSString *numberFormat =
  [NSString stringWithFormat:@"%@.", NSTextListMarkerDecimal];
NSTextList *number = [NSTextList.alloc
                        initWithMarkerFormat:numberFormat
                                     options:kNilOptions];
NSString *numberContent =
  [NSString stringWithFormat:@"\t%@\tNumbered Item\n",
    [number markerForItemNumber:1]];
NSMutableAttributedString *numberItem =
  [NSMutableAttributedString.alloc initWithString:numberContent];
NSMutableAttributedString *numberStyle = NSMutableParagraphStyle.new;
numberStyle.textLists = @[ number ];
[numberItem addAttribute:NSParagraphStyleAttributeName
                   value:numberStyle
                   range:NSMakeRange(0, numberItem.length)];
[string appendAttributedString:numberItem];
Bulleted Item
1.
Numbered Item

Less picky apps like Mail usually don’t need anything extra to correctly display such lists as nested. However, some proper rich text editors such as TextEdit might semantically treat it as a nested list but would still render it as a flat list due to tab stops and indents having default values. So to account for all cases, you should always calculate and set them yourself.

NSParagraphStyleAttributeName.m
// …
bulletStyle.textLists = @[ bullet ];
bulletStyle.headIndent = 48.0;
bulletStyle.tabStops = @[
  [NSTextTab.alloc initWithTextAlignment:NSTextAlignmentNatural
                                location:20.0
                                 options:@{}],
  [NSTextTab.alloc initWithTextAlignment:NSTextAlignmentNatural
                                location:bulletStyle.headIndent
                                 options:@{}]
];
// …
numberStyle.textLists = @[ bullet, number ];
numberStyle.headIndent = 82.0;
numberStyle.tabStops = @[
  [NSTextTab.alloc initWithTextAlignment:NSTextAlignmentNatural
                                location:bulletStyle.headIndent
                                 options:@{}],
  [NSTextTab.alloc initWithTextAlignment:NSTextAlignmentNatural
                                location:numberStyle.headIndent
                                 options:@{}]
];
// …
 
 
Bulleted Item
 
1.
 
Nested Numbered Item
plus some wrapping text
Nested List in TextEdit

Tables

In AppKit, multiple consecutive paragraphs can be arranged into cells of a table via the textBlocks property.

NSParagraphStyleAttributeName.m
NSTextTable *table = NSTextTable.new;
table.numberOfColumns = 2;
int rows = 2;

NSMutableAttributedString *string =
  [NSMutableAttributedString.alloc initWithString:@"\n"];

for (int i = 0; i < table.numberOfColumns * rows; i++) {
  NSTextTableBlock *cell = [NSTextTableBlock.alloc
                               initWithTable:table
                                 startingRow:i / table.numberOfColumns
                                     rowSpan:1
                              startingColumn:i % table.numberOfColumns
                                  columnSpan:1];
  NSMutableParagraphStyle *style = NSMutableParagraphStyle.new;
  style.textBlocks = @[ cell ];

  NSString *content = [NSString stringWithFormat:@"Cell %d\n", i + 1];
  NSMutableAttributedString *cellString =
    [NSMutableAttributedString.alloc initWithString:content];
  [cellString addAttribute:NSParagraphStyleAttributeName
                     value:style
                     range:NSMakeRange(0, cellString.length)];

  cell.borderColor = NSColor.blackColor;
  [cell setWidth:6.0
            type:NSTextBlockAbsoluteValueType
        forLayer:NSTextBlockPadding];
  [cell setWidth:1.0
            type:NSTextBlockAbsoluteValueType
        forLayer:NSTextBlockBorder];

  [string appendAttributedString:cellString];
}
Cell 1
Cell 2
Cell 3
Cell 4
Border Color
Padding
Border

Decorations

We’ve already touched on some of them in the chapter about colors.

Here’s the full list.

NSStrokeWidthAttributeName.m
// 1. positive values also remove the fill
// 2. stroke is always centered, there is no way to change this
[string addAttribute:NSStrokeWidthAttributeName
               value:@0.0 // % of font size
               range:NSMakeRange(17, 19)];
The width of the outline surrounding the text.
NSUnderlineStyleAttributeName.m
// NSUnderlineStyleSingle
// NSUnderlineStyleThick
// NSUnderlineStyleDouble
// (+)
// NSUnderlineStylePatternSolid
// NSUnderlineStylePatternDot
// NSUnderlineStylePatternDash
// NSUnderlineStylePatternDashDot
// NSUnderlineStylePatternDashDotDot
// (+)
// NSUnderlineStyleByWord
[string addAttribute:NSUnderlineStyleAttributeName value:@(
  NSUnderlineStyleSingle
  
  
) range:NSMakeRange(17, 10)];
The style of the line below the text.
NSStrikethroughStyleAttributeName.m
[string addAttribute:NSStrikethroughStyleAttributeName value:@(
  // uses the same values as underline 🤷🏻‍♂️
  NSUnderlineStyleSingle
  
  
) range:NSMakeRange(17, 18)];
The style of the line going through the text.
NSShadowAttributeName.m
NSShadow *shadow = NSShadow.new;
shadow.shadowOffset = NSMakeSize(1.0, 1.0); // x, y
shadow.shadowBlurRadius = 1.0;
shadow.shadowColor = NSColor.grayColor;

[string addAttribute:NSShadowAttributeName
               value:shadow
               range:NSMakeRange(17, 13)];
The style of the shadow behind the text.
Offset
Blur
Color

To define a link, assign an NSURL or an NSString under the NSLinkAttributeName.

NSLinkAttributeName.m
[string addAttribute:NSLinkAttributeName
               value:[NSURL URLWithString:@"https://papereditor.app"]
               range:NSMakeRange(0, 0)];

[string addAttribute:NSLinkAttributeName
               value:@"https://papereditor.app"
               range:NSMakeRange(0, 0)];

By default, links are displayed as blue and underlined. This can be overridden with respective attributes.

Links behave as you would expect them to. When the user presses on a link, the app opens the URL in the default browser. On iOS, long-press opens a preview with a menu.

Both NSTextView and UITextView have delegate methods allowing you to intercept the press and decide what to do with it.

TextView.h
@protocol NSTextViewDelegate
- (BOOL)textView:(NSTextView *)textView
   clickedOnLink:(id)link
         atIndex:(NSUInteger)charIndex;
@end

@protocol UITextViewDelegate
- (nullable UIAction *)textView:(UITextView *)textView
       primaryActionForTextItem:(UITextItem *)textItem
                  defaultAction:(UIAction *)defaultAction;
@end

Attachments

Attachments can be added with the help of the NSTextAttachment data structure containing the data of the file.

Attachment attributes can’t be attached to just any piece of text. Instead, a special placeholder character contained in the constant NSAttachmentCharacter should be marked with NSAttachmentAttributeName and inserted into the attributed string to be recognized as an attachment. For convenience, there is a method called attributedStringWithAttachment that simplifies this process.

NSAttachmentAttributeName.m
NSFileManager *files = NSFileManager.defaultManager;
NSData *data = [@"Having no…" dataUsingEncoding:NSUTF8StringEncoding];
NSFileWrapper *file =
  [NSFileWrapper.alloc initRegularFileWithContents:data];
file.preferredFilename = @"My Nobel Speech.txt";

NSTextAttachment *attachment =
  [NSTextAttachment.alloc initWithData:nil ofType:nil];
attachment.fileWrapper = file;
[string appendAttributedString:
  [NSAttributedString attributedStringWithAttachment:attachment]];
Attachments appear  inline between the text.

Same as with links, both NSTextView and UITextView have chunks of their APIs dedicated to configuring how attachments should behave and be displayed.

Others

There are a few built-in attributes that affect the styling and behavior of the TextView but have no effect outside of it, and thus beyond the scope of this article. I will mention them briefly.

These ones are AppKit-exclusive:

Finally, here are the deprecated attributes, for completeness:

Converting formats

With all the various attributes out of the way, let’s finish the article by talking about some of the ways the NSAttributedString can be converted to and from other representations.

Documents

First of all, you can convert the NSAttributedString to and from a document format e.g. HTML. The primary methods that deal with this are:

NSAttributedString.h
- (instancetype)initWithData:(NSData *)data
                     options:(NSDictionary<NSString, id> *)options
          documentAttributes:(NSDictionary<NSString, id> *)attributes
                       error:(NSError **)error;

- (NSData *)dataFromRange:(NSRange)range
       documentAttributes:(NSDictionary<NSString, id> *)attributes
                    error:(NSError **)error;

- (NSFileWrapper *)fileWrapperFromRange:(NSRange)range
       documentAttributes:(NSDictionary<NSString, id> *)attributes
                    error:(NSError **)error;

The first method takes the data of the format as an array of bytes (packaged inside the NSData object) along with two dictionaries of metadata and tries to make an accurate representation of that format in an NSAttributedString form (sometimes the source format is richer, so you might lose some styles). You can pass a lot of options in these dictionaries. I am not even sure if some of them have any effect on the result. For the basic case, you can leave the attributes dictionary omitted and pass only the name of the format in the options:

FromRtf.m
[NSAttributedString.alloc initWithData:data options:@{
  NSDocumentTypeDocumentOption: NSRTFTextDocumentType
} documentAttributes:nil error:nil];

The second method performs the reverse operation. It returns the NSData byte array for the specified format:

ToRtf.m
[string dataFromRange:NSMakeRange(4, 10) documentAttributes:@{
  NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType
} error:nil];

The third method does the same, but for the formats that are packages (folders) of multiple files. For instance, the RTFD format — the serialized form of the attributed string with attachments.

In total, four document formats are supported both in AppKit and UIKit:

  1. NSPlainTextDocumentType
  2. NSRTFTextDocumentType
  3. NSRTFDTextDocumentType
  4. NSHTMLTextDocumentType

Plus six more that are AppKit-exclusive:

  1. NSMacSimpleTextDocumentType
  2. NSDocFormatTextDocumentType
  3. NSWordMLTextDocumentType
  4. NSWebArchiveTextDocumentType
  5. NSOfficeOpenXMLTextDocumentType
  6. NSOpenDocumentTextDocumentType

For these ones, the additional metadata attributes come in handy.

ToDocx.m
[string fileWrapperFromRange:NSMakeRange(0, 20) documentAttributes:@{
  NSDocumentTypeDocumentAttribute: NSOfficeOpenXMLTextDocumentType,
  NSPaperSizeDocumentAttribute: [NSValue valueWithSize:
                                            NSMakeSize(595.0, 842.0)],
  NSLeftMarginDocumentAttribute: @70,
  NSTopMarginDocumentAttribute:  @70,
  NSViewZoomDocumentAttribute:   @80
} error:nil];

In my experience, all of the rich formats besides the native RTF and RTFD are hit or miss. Styling options that don’t have a counterpart in the world of NSAttributedString default to whatever Apple has decided with no way to configure these mappings. Converting to advanced stuff like DOCX often results in ugly-looking documents with a lot of missing or weird styles.

Images

Since the attributed string has an API to draw itself onto a bitmap canvas, you can string together a chain of methods that would spit out an NSImage/UIImage at the end.

ToImage.m
// AppKit
NSRect bounds =
  [string boundingRectWithSize:NSMakeSize(500.0, CGFLOAT_MAX)
                       options:NSStringDrawingUsesLineFragmentOrigin
                       context:nil];
NSImage *image = [NSImage
  imageWithSize:bounds.size flipped:YES
  drawingHandler:^(NSRect rect) {
    [NSColor.whiteColor set];
    NSRectFill(bounds);
    [string drawInRect:bounds];
    return YES;
  }];

// UIKit
CGRect bounds =
  [string boundingRectWithSize:CGRectMake(500.0, CGFLOAT_MAX)
                       options:NSStringDrawingUsesLineFragmentOrigin
                       context:nil];
UIImage *image = [[UIGraphicsImageRenderer.alloc
  initWithSize:bounds.size];
  imageWithActions:^(UIGraphicsImageRendererContext *context) {
    [UIColor.whiteColor set];
    UIRectFill(bounds);
    [string drawInRect:bounds];
  }];

Printing & PDF

In AppKit and UIKit, converting the attributed string to a PDF format is usually tightly coupled with printing. Finding a direct route to programmatic, synchronous conversion can get tricky.

There are some low-level functions in CoreGraphics that start with CGPDF, but those don’t work with the attributed string directly.

In AppKit, NSPrintOperation is the starting point for PDF rendering. It can render the NSTextView as a PDF with the attributed string in NSTextStorage. That said, the public API allows doing that only through presenting a dialog to the user.

A similar situation is in UIKit. You can pass the attributed string to a printing-related class called UISimpleTextPrintFormatter and then use a few adjacent classes to render the PDF to an array of bytes:

  • UIPrintPageRenderer
  • UIGraphicsBeginPDFContextToData
  • UIGraphicsBeginPDFPage

Clipboard

The serialization format for the attributed string in the clipboard is RTF. When you select a piece of rich text and copy it to the clipboard, the equivalent of this method call is executed:

ToClipboard.m
[textStorage dataFromRange:selectedRange documentAttributes:@{
  NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType
} error:nil];

In case the attributed string has attachments, it is serialized to RTFD which is simply RTF in an .rtfd-suffixed folder along with referenced files. But hold on… How can the contents of the whole folder be copied to the clipboard as a single item? Well, for this purpose Apple has created a special flat variant of RTFD that stores everything in a single string.

And if you’ve read my short overview of the clipboard, the way it actually works is it does not copy just one format, but also every representation that’s less rich than the original.

So for the attributed string with attachments, it would copy:

  • Flat RTFD — the full representation of the attributed string with attachments.
  • RTF — the representation of the attributed without attachments.
  • Plain text — the string part without the attributes.

The receiving app can then choose the richest format it can handle.

PS — I like sweating the details. If you think I might be useful to you → reach out. 😉