NSFont autoscaling via NSStringDrawingContext does not work - swift

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?

Related

How to set a proper location for CIAttributedTextImageGenerator?

I use "CIAttributedTextImageGenerator" to generate a CIImage from text and then I overlay it on my edited image with "CISourceAtopCompositing" :
// Text to image
let font = UIFont.systemFont(ofSize: 78)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
paragraphStyle.firstLineHeadIndent = 5.0
let shadow = NSShadow()
shadow.shadowColor = UIColor.red
shadow.shadowBlurRadius = 5
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.blue,
.paragraphStyle: paragraphStyle,
.shadow: shadow
]
let attributedQuote = NSAttributedString(string: "General Kenobi", attributes: attributes)
let textGenerationFilter = CIFilter(name: "CIAttributedTextImageGenerator")!
textGenerationFilter.setValue(attributedQuote, forKey: "inputText")
textGenerationFilter.setValue(NSNumber(value: Double(inputSizeFactor)), forKey: "inputScaleFactor")
let textImage = textGenerationFilter.outputImage!.oriented(.right)
finalImage = textImage
.applyingFilter("CISourceAtopCompositing", parameters: [ kCIInputBackgroundImageKey: finalImage])
However, the text image is always in the right bottom corner of my edited image:
How can I set it a custom location? For example, if I want it to be in the bottom right corner, or in the center of the edited image?
You can apply transformations to the image before you composite it over the background:
let transformedText = textImage.transformed(by: CGAffineTransform(translationX: 200, y: 300)
I'm afraid you have to calculate the exact position for centering the image yourself. There's no built-in way to center one image ontop another.

How to get the estimated size of a rectangle required to draw NSMutableAttributedString?

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.

NSAttributedString boundingRect returns wrong height

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
}
}

Draw on a PDF using Swift on macOS

My goal is to write text on a PDF, like an annotation.
I achieved it transforming the PDFPage to a NSImage, I drew on the NSImage then I saved the PDF formed by the images.
let image = NSImage(size: pageImage.size)
image.lockFocus()
let rect: NSRect = NSRect(x: 50, y: 50, width: 60, height: 20)
"Write it on the page!".draw(in: rect, withAttributes: someAttributes)
image.unlockFocus()
let out = PDFPage(image: image)
The problem is obviously that out (the new page of the output PDF) is a PDFPage of images and not a regular one. So the output PDF is very big in size and you can't copy and paste anything on it. It's just a sequence of images.
My question is if there's a way to add simple text on a PDF page programmatically without using NSImage. Any idea?
Note: There's this class in iOS programming UIGraphicsBeginPDFPageWithInfo which could be very helpful in my case. But I can't find the similar class for macOS development.
You can create a PDF graphics context on macOS and draw a PDFPage into it. Then you can draw more objects into the context using either Core Graphics or AppKit graphics.
Here's a test PDF I created by printing your question:
And here's the result from drawing that page into a PDF context, then drawing more text on top of it:
Here's the code I wrote to transform the first PDF into the second PDF:
import Cocoa
import Quartz
let inUrl: URL = URL(fileURLWithPath: "/Users/mayoff/Desktop/test.pdf")
let outUrl: CFURL = URL(fileURLWithPath: "/Users/mayoff/Desktop/testout.pdf") as CFURL
let doc: PDFDocument = PDFDocument(url: inUrl)!
let page: PDFPage = doc.page(at: 0)!
var mediaBox: CGRect = page.bounds(for: .mediaBox)
let gc = CGContext(outUrl, mediaBox: &mediaBox, nil)!
let nsgc = NSGraphicsContext(cgContext: gc, flipped: false)
NSGraphicsContext.current = nsgc
gc.beginPDFPage(nil); do {
page.draw(with: .mediaBox, to: gc)
let style = NSMutableParagraphStyle()
style.alignment = .center
let richText = NSAttributedString(string: "Hello, world!", attributes: [
NSFontAttributeName: NSFont.systemFont(ofSize: 64),
NSForegroundColorAttributeName: NSColor.red,
NSParagraphStyleAttributeName: style
])
let richTextBounds = richText.size()
let point = CGPoint(x: mediaBox.midX - richTextBounds.width / 2, y: mediaBox.midY - richTextBounds.height / 2)
gc.saveGState(); do {
gc.translateBy(x: point.x, y: point.y)
gc.rotate(by: .pi / 5)
richText.draw(at: .zero)
}; gc.restoreGState()
}; gc.endPDFPage()
NSGraphicsContext.current = nil
gc.closePDF()

Writing in NSView

I am trying to render text in a NSView canvas. I need to write three lines of text and ignore what's beyond. String.draw(in:withAttributes) with a provided rect seems perfect to do it. My code looks like this:
func renderText(_ string:String, x:Double, y:Double, numberOfLines: Int, withColor color:Color) -> Double {
let font = NSFont.boldSystemFont(ofSize: 11)
let lineHeight = Double(font.ascender + abs(font.descender) + font.leading)
let textHeight = lineHeight * Double(numberOfLines) + font.leading // three lines
let textRect = NSRect(x: x, y: y, width: 190, height: textHeight)
string.draw(in: textRect, withAttributes: [NSFontAttributeName: font, NSForegroundColorAttributeName: color])
return textHeight
}
renderText("Lorem ipsum...", x: 100, y: 100, numberOfLines: 3, withColor: NSColor.white)
Without adjustments, I get only two lines of text rendered:
I am following these guidelines: https://developer.apple.com/library/content/documentation/TextFonts/Conceptual/CocoaTextArchitecture/FontHandling/FontHandling.html#//apple_ref/doc/uid/TP40009459-CH5-SW18
I am missing something?
Ultimately your text makes it to the screen by calling upon the classes that comprise Cocoa's text architecture, so it makes sense to get information about line height directly from these classes. In the code below I've created an NSLayoutManager instance, and set its typesetter behaviour property to match the value of the typesetter that is ultimately used by the machinery created by the function drawInRect:withAttributes:. Calling the layout manager's defaultLineHeight method then gives you the height value you're after.
lazy var layoutManager: NSLayoutManager = {
var layoutManager = NSLayoutManager()
layoutManager.typesetterBehavior = .behavior_10_2_WithCompatibility
return layoutManager
}()
func renderText(_ string:String, x:Double, y:Double, numberOfLines: Int, withColor color:NSColor) -> Double {
let font = NSFont.boldSystemFont(ofSize: 11)
let textHeight = Double(layoutManager.defaultLineHeight(for: font)) * Double(numberOfLines)
let textRect = NSRect(x: x, y: y, width: 190, height: textHeight)
string.draw(in: textRect, withAttributes: [NSFontAttributeName: font, NSForegroundColorAttributeName: color])
return textHeight
}
You are writing your text into a precise typographic bounds, but the system may adjust the size of the text for on-screen presentation to make the text more legible (e.g. substitute screen fonts for for vector fonts). Using an exact typographic bounds can also cause problems for characters with ascenders or descenders that fall outside of bounds. For example the "A-ring" character or a capital E with an grave accent.
To find the bounds of text using rules of the CGContext that it will be drawn in I suggest boundingRectWithSize:options:context: (for NSAttributedString) and boundingRectWithSize:options:attributes:context: (for NSString)
I would try adding a small delta to textHeight -- Doubles are perfectly accurate.