Why are the results of NSAttributedString.size() and NSLayoutManager.boundingRect(forGlyphRange:in:).size so different?
Example code:
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 14.0)]
let container = NSTextContainer()
container.lineFragmentPadding = 0.0
container.lineBreakMode = .byClipping
container.containerSize = NSSize(width: 5e6, height: 5e6)
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(container)
let text = NSTextStorage()
text.addLayoutManager(layoutManager)
text.setAttributedString(NSAttributedString(string: "Hello, world!", attributes: attrs))
var range = NSMakeRange(0, text.length)
print(text.size())
print(layoutManager.boundingRect(forGlyphRange: range, in: container).size)
text.setAttributedString(NSAttributedString(string: "東京", attributes: attrs))
range = NSMakeRange(0, text.length)
print(text.size())
print(layoutManager.boundingRect(forGlyphRange: range, in: container).size)
Here's the result I get:
(80.3837890625, 18.0)
(80.3837890625, 17.0)
(25.85410895660203, 14.0)
(25.85410895660203, 19.463557481765747)
As you can see, although the widths are the same, the heights are not. In the first case the NSLayoutManager reports a smaller height than NSAttributedString, and in the second case a larger one.
What accounts for this difference?
I have read the documentation for these two methods. I have also read the Text System Storage Layer Overview in Apple's archive. I could not find the information there.
Related
I'm building a small test app that draws text into rectangles on images. Said text might sometimes be too long to be drawn into said rectangles, but I thought I would simply use NSStringDrawingContext and NSAttributedString.boundingRect(with:options:context:) in order to generate a scale factor that I'd use to scale the font down to make it fit.
The problem I'm now facing is that this straight up is not working, as actualScaleFactor is always 1.0, even though it should be less than that. To demonstrate, I've prepared a minimal reproducible example.
The image used as input variable image can be found here: https://i.imgur.com/VNd2y8r.png. The text target rectangle rect is marked by the black rectangle in the image.
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left
paragraphStyle.lineBreakMode = .byWordWrapping
let textAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont(name: "Futura-Bold", size: 100)!,
.foregroundColor: NSColor.white,
.paragraphStyle: paragraphStyle,
]
let text = "A very long string that'll be auto-wrapped when it gets drawn"
let attrStr = NSAttributedString(string: text, attributes: textAttributes)
let drawingOptions: NSString.DrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
let context = NSStringDrawingContext()
// counter-intuitively, 1.0 is the "maximum" minimum scale factor
// see also https://developer.apple.com/documentation/uikit/nsstringdrawingcontext/1534020-minimumscalefactor
context.minimumScaleFactor = 1.0
let position = NSPoint(x: 80, y: 140)
let size = NSSize(width: 640, height: 200)
let rect = NSRect(origin: position, size: size)
let newImage = NSImage(size: image.size, flipped: true) { imageRect in
image.draw(in: imageRect)
// let NSStringDrawingContext do its thing
attrStr.boundingRect(with: size, options: drawingOptions, context: context)
print("Actual Scale Factor: \(context.actualScaleFactor)") // is always 1.0
attrStr.setAttributes([.font: font.withSize(font.pointSize * context.actualScaleFactor)], range: NSRangeFromString(text))
attrStr.draw(with: rect, options: drawingOptions, context: context)
return true
}
At this point, newImage should be an image that looks something like this:
(recreated in an image editing tool)
Instead, this is what I get:
This is obviously wrong.
While I was looking for a solution, I found this related SO question from 2013 with a conclusion of "it's broken", but surely this has to be possible somehow by now. iOS and macOS label controls both have this feature, SwiftUI even makes it available as a modifier entitled simply minimumScaleFactor. Is there a replacement API I'm not aware of?
I am trying to get the estimated size of the rectangle required to draw out a NSMutableAttributedString. The numbers that come not does not make any sense to me. I have a UIViewController with a UIlabel (txtField), with a UIlabel.numberOfLines = 3. I would like to estimate the height of this NSMutableAttributedString were i to set UIlabel.numberOfLines= 0.
With reference to the console reading, I do not understand why the estimated height of the rectangle required to draw the entire NSMutableAttributedString is less than that if it were constrained to just 3 lines?
var txtField: UILabel = {
let label = UILabel()
label.numberOfLines = 3
label.translatesAutoresizingMaskIntoConstraints = false
label.lineBreakMode = .byTruncatingTail
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
let content = "Pasture he invited mr company shyness. But when shot real her. Chamber her
observe visited removal six sending himself boy. At exquisite existence if an oh dependent excellent. Are gay head need down draw. Misery wonder enable mutual get set oppose the uneasy. End why melancholy estimating her had indulgence middletons. Say ferrars demands besides her address. Blind going you merit few fancy their. "
let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)]
let attributedString = NSMutableAttributedString.init(string: content, attributes: attributes)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 1.5
paragraphStyle.lineBreakMode = .byTruncatingTail
attributedString.addAttributes([.paragraphStyle : paragraphStyle], range: NSRange(location: 0, length: attributedString.length))
txtField.attributedText = attributedString
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let attText = txtField.attributedText{
let size = CGSize.init(width: txtField.frame.width - 20, height: 1000)
let estimatedFrame = attText.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil)
print("txtField.frame: \(txtField.frame)")
print("estimatedFrame: \(estimatedFrame)")
}
}
CONSOLE:
txtField.frame: (0.0, 0.0, 394.0, 53.333333333333336)
estimatedFrame: (0.0, 0.0, 367.28125, 16.70703125)
This is wrong:
paragraphStyle.lineBreakMode = .byTruncatingTail
Attributed string line breaking is different from label line breaking. Your attributed string needs to have a line break mode that wraps. Otherwise you are measuring the height of just one line.
I calculate the height of an NSAttributedString like this.
let maxSize = NSSize(width: w, height: CGFloat.greatestFiniteMagnitude)
let rect = boundingRect(with: maxSize, options: [.usesFontLeading, .usesLineFragmentOrigin])
let height = rect.integral.size.height
I tried about every "hack" that was mentioned on SO, yet the string height gets more inaccurate the smaller the width gets (the calculated height is larger than the actual height).
According to other posts the following things cause problems with size calculation:
Whitespace
No foreground color
No background color
Widths below 300 don't work (?)
I have found that none of these suggestions make any difference. The attributed string is a concatenation of lines, each having a foregroundColor and backgroundColor. The string also has the following paragraph style.
let pstyle = NSMutableParagraphStyle()
pstyle.lineSpacing = 6
pstyle.lineBreakMode = .byWordWrapping
and a userFixedPitchFont of size 11.
Why does the height error get larger the smaller the width is?
PS: I imagine it has something to do with lineBreak since the error gets larger if more lines are word wrapped.
I found that this solution works. Note that if you don't set the lineFragmentPadding to 0, it produces the same (wrong) results as boundingRect.
extension NSAttributedString {
func sizeFittingWidth(_ w: CGFloat) -> CGSize {
let textStorage = NSTextStorage(attributedString: self)
let size = CGSize(width: w, height: CGFloat.greatestFiniteMagnitude)
let boundingRect = CGRect(origin: .zero, size: size)
let textContainer = NSTextContainer(size: size)
textContainer.lineFragmentPadding = 0
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
layoutManager.glyphRange(forBoundingRect: boundingRect, in: textContainer)
let rect = layoutManager.usedRect(for: textContainer)
return rect.integral.size
}
}
I am attempting to create text that has an outline. I am currently using SKLabelNode with NSAttributedString, which you can now do in SpriteKit as of iOS 11. The problem is, if the stroke width is too thick, then the outline gets cut off by what appears to be the bounding rectangle of the SKLabelNode. Please see below for the image and code.
extension SKLabelNode {
func addStroke(_ strokeColor:UIColor) {
let font = UIFont(name: self.fontName!, size: self.fontSize)
let attributes:[NSAttributedStringKey:Any] = [.strokeColor: strokeColor, .strokeWidth: 20.0, .font: font!]
let attributedString = NSMutableAttributedString(string: " \(self.text!) ", attributes: attributes)
let label1 = SKLabelNode()
label1.horizontalAlignmentMode = self.horizontalAlignmentMode
label1.text = self.text
label1.zPosition = -1
label1.attributedText = attributedString
self.addChild(label1)
}
}
I looked at expanding the frame of the SKLabelNode serving as the border text, but that is a get-only property. I tried to add leading/trailing spaces, but they appear to be automatically trimmed. Using a negative value for strokeWidth works but creates an inner stroke, I'd prefer to have an outer stroke.
Any ideas? Thanks in advance for the help!
Mike
You shouldn't need to create a separate node for the stroke.
Use negative width values to only render the stroke without fill.
Use .foregroundColor to fill.
You should first check to see if an attributed string is already present to ensure you do not clobber it.
Here is the code:
extension SKLabelNode {
func addStroke(color:UIColor, width: CGFloat) {
guard let labelText = self.text else { return }
let font = UIFont(name: self.fontName!, size: self.fontSize)
let attributedString:NSMutableAttributedString
if let labelAttributedText = self.attributedText {
attributedString = NSMutableAttributedString(attributedString: labelAttributedText)
} else {
attributedString = NSMutableAttributedString(string: labelText)
}
let attributes:[NSAttributedStringKey:Any] = [.strokeColor: color, .strokeWidth: -width, .font: font!, .foregroundColor: self.fontColor!]
attributedString.addAttributes(attributes, range: NSMakeRange(0, attributedString.length))
self.attributedText = attributedString
}
}
I create a simple CFAttributedString with Futura-Bold typeface and size 100px:
let font = NSFont(name: "Futura-Bold", size: 100.0)!
When I render that string on a CGContext (with CTFramesetterCreateFrame) I get the following image:
Now the question is how to get real height of this text? As you can see with the above example we're looking at 85px.
When querying font object for various attributes I get the following values:
font.pointSize // 100.0
font.ascender // 103.90
font.descender // -25.99
font.capHeight // 75.40
font.leading // 2.99
font.boundingRectForFont // (-22.7, -34.3994140625, 168.6, 144.29931640625)
Does anyone know to calculate real pixel size of rendered string?
One solution that gives you the value you are seeking is to use the NSString boundingRect(with:options:attributes:) method. By passing in the proper options you get the desired result:
let font = NSFont(name: "Futura-Bold", size: 100)!
let text: NSString = "Hello World!"
let rect = text.boundingRect(with: NSSize(width: 0, height: 0), options: [ .usesDeviceMetrics ], attributes: [ .font: font ], context: nil)
print("Height of \"\(text)\" is \(rect.height)")
Output:
Height of "Hello World!" is 85.1
This works with NSAttributedString as well.
let font = NSFont(name: "Futura-Bold", size: 100)!
let attrStr = NSAttributedString(string: "Hello World!", attributes: [ .font: font ])
let rect2 = attrStr.boundingRect(with: NSSize(width: 0, height: 0), options: [ .usesDeviceMetrics ])
print("Height of \"\(attrStr)\" is \(rect2.height)")
Output:
Height of "Hello World!{
NSFont = "\"Futura-Bold 100.00 pt. P [] (0x7ff6eae563b0) fobj=0x7ff6eaf1ea50, spc=34.00\"";
}" is 85.1
If needed, you can cast a CFAttributedString to NSAttributedString.
let attrStr: CFAttributedString = ... // some CFAttributedString
let rect2 = (attrStr as NSAttributedString).boundingRect(with: NSSize(width: 0, height: 0), options: [ .usesDeviceMetrics ])
In addition to rmaddys's excellent answer I found one more solution on my own which also gives desired results. The trick is to use CTLineGetImageBounds.
let font = NSFont(name: "Futura-Bold", size: 100.0)!
let text = NSAttributedString(string: "Hello World!", attributes: [.font:font])
let line = CTLineCreateWithAttributedString(text)
print(CTLineGetImageBounds(line, textContext))
where textContext is a CGContext on which you render your text. Per Apple docs:
This is required because the context could have settings in it that
would cause changes in the image bounds.
The above code gives the following result:
(7.9, -2.1, 664.2, 85.1)
^^^^