I am trying to replace an image in an NSAttributedString with a scaled copy of itself (to fit a UITextView's width), however, in many (not all) cases, the replaced image is a mask of the original. I assume that there is something in the other attributes of the string which is doing this as both the image extraction and the creation of a new attributed string from the image work perfectly.
Note: answers like this how to resize an image or done as a NSAttributedString NSTextAttachment (or set its initital size) show how to construct a string with images (which I can do) or how to modify the size of a text attachment (again, I can do that) prior to creating the string, but not how to modify in place.
Update
Thanks to suggestions by commentator #Larme I have now solved my original problem of resizing an image in-place by changing the original NSTextAttachment.bounds - note, not replacing it, changing it in situ. I'm not going to post that as an answer to my own question, as it doesn't answer the wider question of replacing an image.
Any tips as to how to proceed gratefully accepted.
Here's the basis:
extension NSAttributedString {
func resizeAttachments(maxWidth: CGFloat) -> NSAttributedString {
var replacement = NSMutableAttributedString(attributedString: self)
func replace(_ image: UIImage, in range: NSRange, dict: [NSAttributedString.Key : Any]) {
// ... see below for variants of this
}
self.enumerateAttributes(in: NSMakeRange(0, self.length),
options: [],
using: {dict, range, stop in
for (key, value) in dict {
if key == .attachment, let attachment = value as? NSTextAttachment {
if
let fw = attachment.fileWrapper, fw.isRegularFile,
let d = fw.regularFileContents,
let image = UIImage(data: d)?.resized(toWidth: maxWidth) {
// removing the resize above makes no difference to the issue
// i.e. replacing the image with itself still causes the same problem
replace(image, in: range, dict: dict)
return // Exit the block. Can't have 2 .attachments.
}
}
}
})
return replacement
}
}
So far, a straight forward enumeration on self, modifying the mutable copy string replacement when an image is encountered. Here's what I get:
From this unscaled original:
So, what have I tried in the replace function?
func replace(_ image: UIImage, in range: NSRange, dict: [NSAttributedString.Key : Any]) {
let newAttachment = NSTextAttachment(image: image)
let newCharacter = NSMutableAttributedString(attachment: newAttachment)
// NB if I replace the entire string with `newCharacter` the image is perfect (but obviously the rest of the string has gone)
// Option 1
replacement.replaceCharacters(in: range, with: newCharacter)
// Option 2
var newDict: [NSAttributedString.Key : Any] = dict
newDict[.attachment] = newAttachment
replacement.addAttributes(newDict, range: range)
// Option 3
replacement.removeAttribute(.attachment, range: range)
replacement.addAttribute(.attachment, value: newAttachment, range: range)
}
I can't find anything anywhere about actually replacing images. As I said, the extraction of the image works fine - if I just return a new attributed string with only the extracted image then it's perfect. If I append the image to the end, it works. It's modifying it in place that seems to be the issue. Has anyone ever done this successfully?
Related
Let's assume I have this code:
let string = "This is a <b>bold</b> text"
let data = Data(string.utf8)
let attributedText = try! NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil)
textField.attributedText = attributedText
Result: This is a bold text
Now my textField is editable, and let's say I have my cursor here:
This is a bold tex|t
You can see my cursor between x and t
I have a variable called cursorPosition where I store the cursor position anytime it changes and in this current case, the value of cursorPosition will be 18
Now my question: Is there a possible way I can get all the NSAttributedString using NSRange. So I want to get all NSAttributedString before my cursor position using 0..<cursorPosition and the result will be:
This is a <b>bold</b> tex
And not
This is a bold tex
You can get an attributed substring of an NSAttributedString using the attributedSubstring method.
let attrStr: NSAttributedString = ... // some attributed string
let range = NSRange(location: 0, length: someLength)
let attrSubStr = attrStr.attributedSubstring(from: range)
But this is not going to give you an HTML string. This is going to give you another NSAttributedString representing the desired range. If you want HTML then you need to take extra steps to convert the attributed string into HTML.
I tried to do like this, but it does not work, the text is not copied
if let urlScheme = URL(string: "instagram-stories://share") {
if UIApplication.shared.canOpenURL(urlScheme) {
let imageData: Data = UIImage(systemName:"pencil.circle.fill")!.pngData()!
let items:[String: Any] = ["public.utf8-plain-text": "text","com.instagram.sharedSticker.backgroundImage": imageData]
UIPasteboard.general.setItems([items])
UIApplication.shared.open(urlScheme, options: [:], completionHandler: nil)
}}
I would really appreciate any advice
2 things I can think of:
First, I am not sure the below data in your array can be properly handled by pastebin
let items:[String: Any] = ["public.utf8-plain-text": "text","com.instagram.sharedSticker.backgroundImage": imageData]
Next it seems that the activity of sharing causes data in the PasteBoard to be lost so I can offer the solution to put valid data into the PasteBoard (I am using string for example, you can use something else" from the completion handler of your sharing action, something like this might solve it:
UIApplication.shared.open(urlScheme, options: [:]) { (_) in
UIPasteboard.general.string =
"click on the screen until the paste button appears: https://google.com"
}
EDIT
It seems your set up was right and on reading the docs, IG stories should handle the Paste automatically as it seems to check the pasteboard when you execute this url scheme: instagram-stories://share - so it seems IG checks the pasteboard and performs a paste programmatically and that is why the pasteboard gets cleared.
Maybe because the image you choose is black on the black instagram background, it seems nothing is shared but with some proper image the result seems fine.
The other thing I noticed after reading their docs, they do not allow you to set captions anymore, I cannot find this key anymore public.utf8-plain-text
Another idea I can offer to share text is to convert text into an image and add it as a sticker as the sticker layer comes on top of the background image layer.
You can find multiple ways to convert text to an image and it is not relevant to your solution, here is one way I used
So bringing the code together, I have this:
// Just an example to convert text to UIImage
// from https://stackoverflow.com/a/54991797/1619193
extension String {
/// Generates a `UIImage` instance from this string using a specified
/// attributes and size.
///
/// - Parameters:
/// - attributes: to draw this string with. Default is `nil`.
/// - size: of the image to return.
/// - Returns: a `UIImage` instance from this string using a specified
/// attributes and size, or `nil` if the operation fails.
func image(withAttributes attributes: [NSAttributedString.Key: Any]? = nil, size: CGSize? = nil) -> UIImage? {
let size = size ?? (self as NSString).size(withAttributes: attributes)
return UIGraphicsImageRenderer(size: size).image { _ in
(self as NSString).draw(in: CGRect(origin: .zero, size: size),
withAttributes: attributes)
}
}
}
// Then inside some function of yours
func someFunction() {
if let urlScheme = URL(string: "instagram-stories://share") {
if UIApplication.shared.canOpenURL(urlScheme) {
let imageData: Data = UIImage(named: "bg")!.pngData()!
let textImage: Data = "Shawn Test".image(withAttributes: [.foregroundColor: UIColor.red,
.font: UIFont.systemFont(ofSize: 30.0)],
size: CGSize(width: 300.0, height: 80.0))!.pngData()!
let items = ["com.instagram.sharedSticker.stickerImage": textImage,
"com.instagram.sharedSticker.backgroundImage": imageData]
UIPasteboard.general.setItems([items])
UIApplication.shared.open(urlScheme, options: [:], completionHandler: nil)
}
}
}
I then see this in IG stories with correct background and text as sticker which can be moved.
Only downside of using the sticker is you cannot edit the text in Instagram.
Regarding the research looks like the only one workaround to have a text/link copied in the Pasteboard when IG Story is opened is to use:
UIPasteboard.general.string = "your link here"
but you need to do it with a delay - like:
UIApplication.shared.open(instagramStoryURL, options: [:]) { success in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UIPasteboard.general.string = "your link here"
}
}
to try to be sure the it won't override:
UIPasteboard.general.items
that contains, for example, "com.instagram.sharedSticker.stickerImage"
Also, please be careful with a delay - as iOS has some privacy restrictions to allow copy data to UIPasteboard when the App is in background (based on the tests we have less than 1 second to do that.
It means that you could try to copy the link this way:
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.willResignActiveNotification, object: nil)
}
#objc func appMovedToBackground() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
UIPasteboard.general.string = "your link here"
}
}
Anyway, there is one obvious inconvenience.
Each time you try to call "instagram-stories://share" API the first time - you face a system popup that asks for the permisson to allow to open Instagram and also to allow paste the data.
In this case we'll lose, for example, "com.instagram.sharedSticker.stickerImage" data as by the delay it will be overrided by UIPasteboard.general.string.
But we could to make it expected for users by any UI/UX solution with instructions/guide.
There is a problem with QR code generation using the following simple code:
override func viewDidLoad() {
super.viewDidLoad()
let image = generateQRCode(from: "Hacking with Swift is the best iOS coding tutorial I've ever read!")
imageView.image = image
}
func generateQRCode(from string: String) -> UIImage? {
let data = string.data(using: String.Encoding.ascii)
if let filter = CIFilter(name: "CIQRCodeGenerator") {
filter.setValue(data, forKey: "inputMessage")
let transform = CGAffineTransform(scaleX: 5.3, y: 5.3)
if let output = filter.outputImage?.transformed(by: transform) {
return UIImage(ciImage: output)
}
}
return nil
}
This code produces the following image:
But when magnifying any corner marker, we can see the difference in border thickness:
I. e. not every scale value produces correct final image. How to fix it out?
The behavior you show is expected whenever you use a non-integer scale, such as 5.3. If having consistent marker widths is something you care about, use only integer scales, such as 5 or 6.
After a holiday related nightmare with version control, I can't work out why the below is no longer working.
I'm trying to save attributed text to an .rtf file which is then read later.
This was working but now isn't for some reason I can't understand after a couple of hours of going through line by line.
I'm trying to save the contents of a textview to a .rtf file with its attributes like this :
func saveExistingScene() {
let textView = textViewOutlet
let fileURL = getFileURL() //shared function to get the URL of an existing RTF file: there’s a different one for “setFileURL” when saving a new scene
textView?.attributedText = NSAttributedString(string: (textView?.text)!, attributes: nil)
if let attributedText = textView?.attributedText {
let documentAttributes: [NSAttributedString.DocumentAttributeKey: Any] = [.documentType: NSAttributedString.DocumentType.rtf]
do {
let rtfData = try attributedText.data(from: NSRange(location: 0, length: attributedText.length), documentAttributes: documentAttributes)
let rtfString = String(data: rtfData, encoding: .utf8) ?? ""
try rtfString.write(to: fileURL, atomically: true, encoding: .utf8)
print("Saved an update")
} catch {
print("failed to save an update with error: \(error)")
}
}
The text can be changed by the user to bold and italics when they press CMD+B or CMD+I as per here (haven't put them all as don't think there is a need)
let makeBold: [String : Any] = [
NSAttributedStringKey.font.rawValue: UIFont(name: "AvenirNext-Bold", size: 15.0)!, NSAttributedStringKey.foregroundColor.rawValue: UIColor.white
]
which is enabled via key commands. This works fine and is reflected in the UITextView properly.
The generic colour of the text in the textview is set as white in viewDidLoad by
textViewOutlet.UIColour = white
The file is saving fine and creating an .rtf file but the attributes are all being stripped away and leaves it in helvetica, in black with no bold or italics.
This is so frustrating because it was working at some point in the few days before going on holiday! Help appreciated!
UPDATE : I've added a print statement below the 'if let' and it is printing out in RTF format but is defaulting to Helvetica size 12 rather than the attributes that are actually in the textview (white, avenir, mix of bold/italic/normal etc). Does that help anyone!?
In an NSAttributedString, a range of letters has a link attribute and a custom color attribute.
In Xcode 7 with Swift 2, it works:
In Xcode 8 with Swift 3, the custom attributed color for the link is always ignored (it should be orange in the screenshot).
Here's the code for testing.
Swift 2, Xcode 7:
import Cocoa
import XCPlayground
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orangeColor(), range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.selectable = true
tf.stringValue = text
tf.attributedStringValue = attr
XCPlaygroundPage.currentPage.liveView = tf
Swift 3, Xcode 8:
import Cocoa
import PlaygroundSupport
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.isSelectable = true
tf.stringValue = text
tf.attributedStringValue = attr
PlaygroundPage.current.liveView = tf
I've sent a bug report to Apple, but in the meantime if someone has an idea for a fix or workaround in Xcode 8, that would be great.
Apple Developer has answered:
Please know that our engineering team has determined that this issue behaves as intended based on the information provided.
And they explain why it worked before but doesn't anymore:
Unfortunately, the previous behavior (attributed string ranges with NSLinkAttributeName rendering in a custom color) was not explicitly supported. It happened to work because NSTextField was only rendering the link when the field editor was present; without the field editor, we fall back to the color specified by NSForegroundColorAttributeName.
Version 10.12 updated NSLayoutManager and NSTextField to render links using the default link appearance, similar to iOS. (see AppKit release notes for 10.12.)
To promote consistency, the intended behavior is for ranges that represent links (specified via NSLinkAttributeName) to be drawn using the default link appearance. So the current behavior is the expected behavior.
(emphasis mine)
This answer is not a fix for the issue of NSLinkAttributeName ignoring custom colors, it's an alternative solution for having colored clickable words in NSAttributedString.
With this workaround we don't use NSLinkAttributeName at all, since it forces a style we don't want.
Instead, we use custom attributes, and we subclass the NSTextField/NSTextView to detect the attributes under the mouse click and act accordingly.
There's several constraints, obviously: you have to be able to subclass the field/view, to override mouseDown, etc, but "it works for me" while waiting for a fix.
When preparing your NSMutableAttributedString, where you would have set an NSLinkAttributeName, set the link as an attribute with a custom key instead:
theAttributedString.addAttribute("CUSTOM", value: theLink, range: theLinkRange)
theAttributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: theLinkRange)
theAttributedString.addAttribute(NSCursorAttributeName, value: NSCursor.arrow(), range: theLinkRange)
The color and content for the link is set. Now we have to make it clickable.
For this, subclass your NSTextView and override mouseDown(with event: NSEvent).
We will get the location of the mouse event in the window, find the character index in the text view at that location, and ask for the attributes of the character at this index in the text view's attributed string.
class MyTextView: NSTextView {
override func mouseDown(with event: NSEvent) {
// the location of the click event in the window
let point = self.convert(event.locationInWindow, from: nil)
// the index of the character in the view at this location
let charIndex = self.characterIndexForInsertion(at: point)
// if we are not outside the string...
if charIndex < super.attributedString().length {
// ask for the attributes of the character at this location
let attributes = super.attributedString().attributes(at: charIndex, effectiveRange: nil)
// if the attributes contain our key, we have our link
if let link = attributes["CUSTOM"] as? String {
// open the link, or send it via delegate/notification
}
}
// cascade the event to super (optional)
super.mouseDown(with: event)
}
}
That's it.
In my case I needed to customize different words with different colors and link types, so instead of passing just the link as a string I pass a struct containing the link and additional meta information, but the idea is the same.
If you have to use an NSTextField instead of an NSTextView, it's a bit trickier to find the click event location. A solution is to create an NSTextView inside the NSTextField and from there use the same technique as before.
class MyTextField: NSTextField {
var referenceView: NSTextView {
let theRect = self.cell!.titleRect(forBounds: self.bounds)
let tv = NSTextView(frame: theRect)
tv.textStorage!.setAttributedString(self.attributedStringValue)
return tv
}
override func mouseDown(with event: NSEvent) {
let point = self.convert(event.locationInWindow, from: nil)
let charIndex = referenceView.textContainer!.textView!.characterIndexForInsertion(at: point)
if charIndex < self.attributedStringValue.length {
let attributes = self.attributedStringValue.attributes(at: charIndex, effectiveRange: nil)
if let link = attributes["CUSTOM"] as? String {
// ...
}
}
super.mouseDown(with: event)
}
}