Apple rich text fundamentals 👨🏻🎨
NSAttributedString
.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:
- A regular text string.
- Key-value pairs of attributes attached to ranges of text within the string.
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:
location
— the index of the character the range starts on.length
— the number of characters the range spans for.
The API of NSAttributedString
can be divided into four chunks:
Reading
- Query the attribute at the specified
location
. - Traverse the ranges of attributes within the specified range.
- Query the attribute at the specified
Updating
- Add the attribute to the specified range.
- Remove the attribute from the specified range.
Converting
- Convert either the whole string or a piece of it to other document formats.
- Create an attributed string from
NSData
orNSFileWrapper
representing a format.
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.
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
.
// 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.
[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.
// purple
[NSColor.redColor blendedColorWithFraction:0.5
ofColor:NSColor.blueColor];
Fonts
Fonts are defined through the NSFont
/UIFont
object consisting mainly of two properties:
- Name
- Size
In addition to the font attribute itself, you have four other attributes that adjust typography-related properties.
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.
- (NSFont *)convertFont:(NSFont *)font
toHaveTrait:(NSFontTraitMask)trait;
- (NSFont *)convertFont:(NSFont *)font
toNotHaveTrait:(NSFontTraitMask)trait;
Here’s how one would use it:
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:
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.
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.
Paragraph
The second group controls the layout within a single paragraph and between neighboring paragraphs.
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.
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.
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. 🤔
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.
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.
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. TheNSWritingDirectionAttributeName
constant is a character-level attribute that provides a higher-level alternative to the inclusion of explicit bidirectional control characters in text. It is theNSAttributedString
equivalent of the HTML markup usingbdo
element with thedir
attribute.
// 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.
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:
- It must start with a marker.
- The marker has to be surrounded by the tab characters.
- The
textLists
paragraph style property must be set describing the type of the list and the types of all parent lists (if any).
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.
Tables
In AppKit, multiple consecutive paragraphs can be arranged into cells of a table via the textBlocks
property.
Decorations
We’ve already touched on some of them in the chapter about colors.
Here’s the full list.
Links
To define a link, assign an NSURL
or an NSString
under the NSLinkAttributeName
.
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.
@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.
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.
NSTextEffectAttributeName
- The only available effect is
NSTextEffectLetterpressStyle
.
- The only available effect is
NSTrackingAttributeName
- Same as
NSKernAttributeName
, but not serialized to RTF. - Apart from that, seems to behave exactly the same way. 🤷🏻♂️
- Same as
These ones are AppKit-exclusive:
NSCursorAttributeName
- The value is the
NSCursor
object. - Defines the cursor that is shown when hovering over a piece of text.
- The value is the
NSToolTipAttributeName
- The value is
NSString
. - Defines the tooltip that is shown when hovering over a piece of text.
- The value is
NSGlyphInfoAttributeName
- The value is the
NSGlyphInfo
structure. - Allows overriding the glyph that
NSLayoutManager
picks for a character. - Works only with fonts where multiple glyphs map to the same character.
- The value is the
NSTextAlternativesAttributeName
- The value is the
NSTextAlternatives
structure. - Allows specifying semantical alternatives for a piece of text.
- The value is the
NSSpellingStateAttributeName
NSSpellingStateSpellingFlag
NSSpellingStateGrammarFlag
NSMarkedClauseSegmentAttributeName
- Marked text is a piece of text with a yellow background. The built-in use-case for it is highlighting the in-progress span of text behind the caret that needs multiple keystrokes to get to the desired character(s).
- This attribute is probably used to do some semantical splitting inside a long span of marked text.
Finally, here are the deprecated attributes, for completeness:
NSObliquenessAttributeName
- Skews the text.
NSExpansionAttributeName
- Stretches or shrinks the text.
NSVerticalGlyphFormAttributeName
- Vertical text.
- AppKit-exclusive.
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:
- (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
:
[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:
[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:
NSPlainTextDocumentType
NSRTFTextDocumentType
NSRTFDTextDocumentType
NSHTMLTextDocumentType
Plus six more that are AppKit-exclusive:
NSMacSimpleTextDocumentType
NSDocFormatTextDocumentType
NSWordMLTextDocumentType
NSWebArchiveTextDocumentType
NSOfficeOpenXMLTextDocumentType
NSOpenDocumentTextDocumentType
For these ones, the additional metadata attributes come in handy.
[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.
// 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:
[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.