While working with text in your iOS projects, you might have encountered the following problem: Given a fixed frame, what would be the optimal font size to fit the text exactly inside that frame? Here at PSPDFKit, we’ve encountered this problem a few times and attempted a variety of solutions. In the end, we arrived at a binary search algorithm that leverages the built-in attributed string and CoreText APIs to figure out an optimal font size. In this post, we’ll talk about the challenges we faced, and then we’ll go over the algorithm and show how you can leverage it in your own projects.
Our Challenges
PDF text annotations always define a bounding box. When that box is scaled, we — in some cases — want to proportionally resize the text as well, so that it fits the new bounding box size exactly. Another challenge is that of annotations created with PDF editors on different platforms. Their text sizing often differs from what’s available on iOS, so we might need to slightly adjust the font size to avoid text clipping during rendering.
Proportional Resizing
Text annotations in PSPDFKit have a special resizing behavior when they’re resized using the bottom-right resize knob. Instead of just adjusting the text bounding box and reflowing the text, we also scale the font size to improve the user experience when aligning text with existing PDF contents — say a form or signature box.
In the past, we adjusted the font size of our text by calculating the geometric mean of the bounding box scale factors in each direction and using that to adjust the font size. This worked OK in certain simple cases and was extremely fast, but it sometimes resulted in a bad text clipping when dealing with longer multiline text and non-uniform scaling. Switching to the binary search algorithm made this significantly better without any noticeable performance impact.
Old Behavior | With New Text Sizing |
---|---|
Avoiding Clipping
The binary search algorithm comes in handy here as well. We detect if the text is meant to fit the bounding box, and if so, we run the algorithm to slightly adjust the font size until the text fits perfectly. This font size change is applied transiently during rendering and is not preserved in any way. If the user performs any additional edits to the text, we resize the bounding box so it adapts to iOS text sizing metrics.
Without Text Fitting | With Text Fitting |
---|---|
Building box in red for visualization purposes.
Text Sizing Algorithm
We implemented the text sizing algorithm as an NSAttributedString
extension. The minSize
and maxSize
size parameters can be constants that define a reasonably wide range of possible font size choices. They could also be custom-tailored for a particular use case to improve search performance. The other important parameter is the available content size, which we obtain from the annotation bounding box. We also found it useful to add a separate insets parameter to slightly tweak the spacing between the bounding box and text, as well as to add extra spacing for any trailing blank lines.
This binary search could either be implemented recursively or iteratively. We chose the latter to guarantee avoiding extra method invocations. Just as with any good binary search algorithm, we loop until our range is narrow enough to consider having found the solution. If we haven’t found the solution, we calculate the midpoint between the minimum and maximum font size and then perform text measurements on an updated attributed string. If the text fits inside the box, we need to look for a larger font size; if it doesn’t, we need to look for a smaller text size. We also impose a limit to make sure we never get a runaway search. We return the result as an NSNumber
to achieve better Objective-C interoperability:
extension NSAttributedString { @objc(pspdf_fittingFontSizeBetweenSize:andSize:inSafeContentSize:addingInsets:error:) /// Uses a binary search algorithm to find a font size that fits well inside the provided constraints. /// - Parameters: /// - minSize: The minimum font size for the search. /// - maxSize: The maximum font size for the search. /// - safeContentSize: The width and height to which the frame size will be constrained. /// - insets: Optional insets to be added between the text and its resulting bounds. /// - Returns: The found optimal font size, or an error if the search failed. func fittingFontSize(between minSize: CGFloat, and maxSize: CGFloat, in safeContentSize: CGSize, insets: CGSize = .zero) throws -> NSNumber { let mutableString = NSMutableAttributedString(attributedString: self) var foundMinSize = minSize var foundMaxSize = maxSize var limit = 20 while foundMaxSize - foundMinSize > 0.1 { limit -= 1 if limit == 0 { throw StringSizingError.limitError } let fontSize = foundMinSize + (foundMaxSize - foundMinSize) / 2.0 try mutableString.updateToFontSize(fontSize) // We ignore the height constraint to test whether the text fits after sizing. let constraints = CGSize(width: safeContentSize.width, height: CGFloat.greatestFiniteMagnitude) let size = mutableString.size(constrainedTo: constraints, insets: insets) // Newly laid out size is smaller than safe content size. We need to search // for a larger font size. if (safeContentSize.width - size.width >= -insets.width) && safeContentSize.height >= size.height { foundMinSize = fontSize // Newly laid out size is bigger than safe content size. We need to search // for a smaller font size. } else { foundMaxSize = fontSize } } return NSNumber(value: Float(foundMinSize)) } }
We’re assuming that the entire attributed string has a single font attribute, which we can freely adjust with the following helper:
extension NSMutableAttributedString { /// Updates the font size of a mutable attributed string if there's a font size assigned to the whole string. /// - Parameter newFontSize: The desired font size. /// - Throws: An error of type `StringSizingError`. @objc(pspdf_updateToFontSize:error:) func updateToFontSize(_ newFontSize: CGFloat) throws { precondition(newFontSize > 0) if length == 0 { throw StringSizingError.parameterError } guard let font = self.attribute(.font, at: 0, effectiveRange: nil) as? UIFont else { throw StringSizingError.incompatibleInstanceError } addAttribute(.font, value: font.withSize(newFontSize), range: NSRange(location: 0, length: length)) } }
The text sizing code uses CTFramesetter
APIs to measure the dimensions of the attributed string. We constrain the width and are interested in how well the height fits within the available size constraints:
extension NSAttributedString { /// Determines the frame size needed for the attributed string. /// - Parameters: /// - constraints: The width and height to which the frame size will be constrained. /// - insets: Optional insets to be added between the text and its resulting bounds. /// - Returns: The actual dimensions for the given string range and constraints. @objc(pspdf_sizeConstrainedTo:addingInsets:) func size(constrainedTo constraints: CGSize, insets: CGSize = .zero) -> CGSize { let framesetter = CTFramesetterCreateWithAttributedString(self) let zeroRange = CFRange(location: 0, length: 0) let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, zeroRange, nil, constraints, nil) return CGSize(width: suggestedSize.width + insets.width, height: suggestedSize.height + insets.height) } }
And with that, we have an algorithm that sizes our text.
Conclusion
While using an iterative search algorithm sounds like a crude and inefficient way to fit text to a bounding box, we found it to be the best option. The binary search ensures that the solution is found in a reasonable number of iterations. Even with a wide starting search range of 4 pt to 200 pt, the search typically concludes in less than 10 iterations. This can be further reduced when resizing text or adjusting for font rendering differences by narrowing the search range around the starting font size.