In my iOS/swift project I am loading a RTF document to a UITextView with the code below. The RTF itself contains styled text like "... blah blah [ABC.png] blah blah [DEF.png] blah..." wich is loaded to the UITextView just fine.
Now, I want to replace all occurrences of [someImage.png] with an actual image as NSTextAttachment. How can I do that?
I am aware of the possibility to embed images in RTF documents, but I can not do that in this project.
if let rtfPath = Bundle.main.url(forResource: "testABC", withExtension: "rtf")
{
do
{
//load RTF to UITextView
let attributedStringWithRtf = try NSAttributedString(url: rtfPath, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil)
txtView.attributedText = attributedStringWithRtf
//find all "[ABC.png]" and replace with image
let regPattern = "\\[.*?\\]"
//now...?
}
}
Here is something that you could do.
Note: I'm not a Swift Developper, more an Objective-C one, so there may be some ugly Swift code (the try!, etc.). But it's more for the logic on using NSRegularExpression (which I used in Objective-C since it's shared in CocoaTouch)
So the main line directives:
Find where are the images placeholders.
Create a NSAttributeString/NSTextAttachment from it.
Replace the placeholder with the previous attributed string.
let regPattern = "\\[((.*?).png)\\]"
let regex = try! NSRegularExpression.init(pattern: regPattern, options: [])
let matches = regex.matches(in: attributedStringWithRtf.string, options: [], range: NSMakeRange(0, attributedStringWithRtf.length))
for aMatch in matches.reversed()
{
let allRangeToReplace = attributedStringWithRtf.attributedSubstring(from: aMatch.range(at: 0)).string
let imageNameWithExtension = attributedStringWithRtf.attributedSubstring(from: aMatch.range(at: 1)).string
let imageNameWithoutExtension = attributedStringWithRtf.attributedSubstring(from: aMatch.range(at: 2)).string
print("allRangeToReplace: \(allRangeToReplace)")
print("imageNameWithExtension: \(imageNameWithExtension)")
print("imageNameWithoutExtension: \(imageNameWithoutExtension)")
//Create your NSAttributedString with NSTextAttachment here
let myImageAttribute = ...
attributedStringWithRtf.replaceCharacters(in: imageNameRange, with: myImageAttributeString)
}
So what's the idea?
I used a modify pattern. I hard-wrote "png", but you could change it. I added some () to get easily the interesting parts. I thought that you may wanted to retrieve the name of the image, with or without the .png, that's why I got all theses print(). Maybe because you saved it in your app, etc. If you need to add the extension as a group, you may want to add it into parenthesis in your regPattern and check what aMatch.range(at: ??) to call. for using Bundle.main.url(forResource: imageName, withExtension: imageExtension)
I used the matches.reversed() because if you modify the length of the "match" with a replacement of different length, the previous ranges will be off. So starting from the end could do the trick.
Some code to transform a UIImage into NSAttributedString through NSTextAttachment: How to add images as text attachment in Swift using nsattributedstring
Related
I'm trying to create a collaborative note-taking app, where the notes are saved as NSAttributedStrings in the iOS app since they contain both images and text.
Can I save a NSAttributedString to Cloud Firestore? If I can, how do I convert the NSAttributedString to a format that's readable by an Android device and website? If it's not possible, what format do I save a note in that contains both images and text (similar to Evernote) that will work across platforms?
EDIT: Are there any markup languages (e.g. HTML, XML) that can be stored in Firestore?
As you can see from the documentation, Firestore strings can only store UTF-8 characters, so you will not be able to store it natively.
An NSAttributedString is not simply "readable" by Android. As far as I can see, there is no direct Android equivalent. The closest thing is a Spannable, but it's not really very close.
I suggest abandoning the idea that you can store OS-specific data in Firestore for use across client platforms. You will have a much easier time if you reduce your data down to the primitive types that Firestore can store.
As Doug mentioned in his answer, trying to use NSAttributedString cross platform is challenging as there is no direct Android equivalent so it's probably best to keep the data as primitives.
...But the short answer is: Yes, you can store an NSAttributedString in Firestore because Firestore supports NSData objects.
If you really want to go cross platform with your string style, one thought is to understand that an NSAttributed string is a string with a dictionary of key: value pairs that define the strings look. So you could store primitives in Firestore and then use the appropriate platforms functions to re-assemble the string.
So the string could be stored in Firestore like this
string_0 (a document)
text: "Hello, World"
attrs:
font: "Helvetica"
size: "12"
color: "blue"
You could then read that in as create an attributed string based on those attributes.
That being said, I can get you 1/2 way there on the iOS/macOS side if you really want to store an NSAttributed string in Firestore.
Here's a function that creates an NSAttributedString, archives it and stores the data in Firestore.
func storeAttributedString() {
let quote = "Hello, World"
let font = NSFont.boldSystemFont(ofSize: 20)
let color = NSColor.blue
let intialAttributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
]
let attrString = NSAttributedString(string: quote, attributes: intialAttributes)
let archivedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: attrString, requiringSecureCoding: false)
let dict: [String: Any] = [
"attrString": archivedData
]
let attrStringCollection = self.db.collection("attr_strings")
let doc = attrStringCollection.document("string_0")
doc.setData(dict)
}
then to read it back, here's the function that reads it and displays the attributed string an a macOS NSTextField.
func readAttributedString() {
self.myField.allowsEditingTextAttributes = true //allows rich text
let attrStringCollection = self.db.collection("attr_strings")
let doc = attrStringCollection.document("string_0")
doc.getDocument(completion: { snapshot, error in
if let err = error {
print(err.localizedDescription)
return
}
guard let snap = snapshot else { return }
let archivedData = snap.get("attrString") as! Data
let unarchivedData: NSAttributedString? = try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(archivedData) as? NSAttributedString
self.myField.attributedStringValue = unarchivedData!
})
}
I'm reading a rtf file but output result shows me simple string containing tags, It doesn't appear as NSAttributedString as it should
here is my code
if let rtfPath = Bundle.main.url(forResource: "SampleHTML", withExtension: "rtf") {
do {
let attributedStringWithRtf: NSAttributedString =
try NSAttributedString(url: rtfPath, options: [NSAttributedString.DocumentReadingOptionKey.documentType:
NSAttributedString.DocumentType.rtf], documentAttributes: nil)
}catch let error {
print("Got an error \(error)")
}
}
After reading the file its shows attribute string as :
< p >< em>Review the following information. Then select the best answer and click < strong>Submit< /strong>. After answering, click < strong>Next Question< /strong> to move forward.< /em>< /p> .
I've inserted spaces in tag to clarify the issue, without spaces StackOverflow applies tag to text, what am I missing here ?
After RTF to NSAttributedString you got (you can use code tag of SO to avoid the HTML tags to be interpreted):
<p><em>Review the following information. Then select the best answer and click <strong>Submit</strong>. After answering, click <strong>Next Question</strong> to move forward.</em></p>
So now, you have a HTML AttributedString.
And like RTF, there is a way to get NSAttributedString from HTML String.
So let's get first the HTML String: let htmlString = attributedStringWithRtf.string.
For the rest, the method is almost the same as the one with used, with changes: NSAttributedString.DocumentType.html instead of NSAttributedString.DocumentType.rtf (seems obvious), and there is an init with data instead of path, so you just have to use let htmlData = htmlString.data(using:.utf8)
So the code:
let rtfPath = Bundle.main.url(forResource: "SampleHTML", withExtension: "rtf")
let attributedStringWithRtf: NSAttributedString = NSAttributedString(url: rtfPath, options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil)
let htmlString = attributedStringWithRtf.string
let htmlData = htmlString.data(using:.utf8)
let attributedString: NSAttributedString = NSAttributedString(data: htmlData, options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil)
Note: I am not using if let, try/catch on purpose because you already use them correctly, and I'm more interested in the logic behind your issue and how to get away with it.
Now, after discussion in comment:
You save HTML Text (ie with HTML tags) into a RTF file.
Clearly, that's overkill, because as you can see in the solution I gave you do twice the conversion to get the final NSAttributedString.
So, what I'd do is either use entirely RTF File, and set the RTF tags equivalent for <p>, <em>, <strong>, etc instead of the HTML ones.
Save you HTML String (with HTML tags) in a simple .txt.
For instance, I saved a RTF File using TextEdit.app, and the real content is:
{\rtf1\ansi\ansicpg1252\cocoartf1504\cocoasubrtf830
{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\paperw11900\paperh16840\margl1440\margr1440\vieww10800\viewh8400\viewkind0
\pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\partightenfactor0
\f0\fs24 \cf0 myText}
And I just wrote myText, RTF adding a lot of if own tags.
I'm creating a reading list app, and I'd like to pass the read time of a user added link to a table cell in their reading list - and the only way to get that number is from that page's word count. I've found a few solutions, namely Parsehub, Parse and Mercury but they seem to be geared more towards use cases that need more advanced things to be scraped from a url. Is there a simpler way in Swift to calculate word count of a url?
First of all, you need to parse the HTML. HTML can only be parsed reliably with dedicated HTML parser. Please don't use Regular Expressions or any other search method to parse HTML. You may read it why from this link. If you are using swift, you may try Fuzi or Kanna. After you get the body text with any one of the library, you have to remove extra white spaces and count the words. I have written some basic code with Fuzi library for you to get started.
import Fuzi
// Trim
func trim(src:String) -> String {
return src.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
// Remove Extra double spaces and new lines
func clean(src:String) ->String {
return src.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression)
}
let htmlUrl = URL(fileURLWithPath: ((#file as NSString).deletingLastPathComponent as NSString).appendingPathComponent("test.html"))
do {
let data = try Data(contentsOf: htmlUrl)
let document = try HTMLDocument(data: data)
// get body of text
if let body = document.xpath("//body").first?.stringValue {
let cleanBody = clean(src: body)
let trimmedBody = trim(src:cleanBody)
print(trimmedBody.components(separatedBy: " ").count)
}
} catch {
print(error)
}
If you are fancy, you may change my global functions to String extension or you can combine them in a single function. I wrote it for clarity.
We're adding the finishing touches to an app we're working on, and that apparently means putting an entire "Terms and Conditions" and "FAQs" section, formatting, bullets, breaks and all.
So I tried copy-pasting it into a textView with "editable" set to off, which kept the bullets, but not the bolded text.
Now, I've done attributed string before, and I have to say, I'm not sure it will be easy to do that on some 12-pages worth of paragraphs, bulleted lists and breaks that are likely to change in a few years or so.
So my question is, is there a way to do this without using attributed string?
Barring that, perhaps there's a way to loop through the text, and look for a written tag that will apply the attributes?
EDIT:
Update. It's been suggested I use HTML tags, and web view. That's what was done for the FAQs (which uses a label), I neglected to mention I tried that too.
For some reason, it just shows a blank textview, albeit a large-sized one, as if there's text in it (there isn't any). Strange that copy-pasting works but this doesn't.
Here's my code for it:
override func awakeFromNib() {
super.awakeFromNib()
termsTitle.text = "Terms and Conditions"
htmlContent = "<p style=\"font-family:Helvetica Neue\"><br/><strong><br/> BLA BLA BLA BLA BLA BLA x 12 Pages"
do {
let str = try NSAttributedString(data: htmlContent.dataUsingEncoding(NSUnicodeStringEncoding, allowLossyConversion: true)!, options: [ NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType], documentAttributes: nil)
termsTextView.attributedText = str
} catch {
print("Dim background error")
}
}
I'm pretty sure you can't do this in a Textview without using AttributedString. A possible solution would be using a WebView. Converting your "Terms and Conditions" and "FAQ" to HTML would probably be much easier than using an AttributedString.
If you still want to use your HTML in a UITextView you can try this function:
func getAttributedString(fileName: String) -> NSAttributedString? {
if let htmlLocation = NSBundle.mainBundle().URLForResource(fileName, withExtension: "html"), data = NSData(contentsOfURL: htmlLocation) {
do {
let attrString = try NSAttributedString(data: data, options: [NSDocumentTypeDocumentAttribute:NSHTMLTextDocumentType], documentAttributes: nil)
return attrString
} catch let err as NSError {
print("Attributed String Creation Error")
print(err.localizedDescription)
return nil
}
} else {
return nil
}
}
This function assumes you have a .html file in your main bundle. You pass it the name (minus extension) of the file (that should be in your project) and then use it like so:
textView.attributedText = getAttributedString("TermsAndConditions")
Just to clarify, the textView is a #IBOutlet on a View Controller in this example.
This function returns nil if either the .html file does not exist or the NSAttributedString conversion failed.
I am reading strings out of a Localizable.strings which contains something like that which is basically what you have in an strings.xml of an Android app
"testShort" = "A <b>short</b>\ntest with another<b>bold text</b>";
The bold and and line feed are the only two formatting attributes I have in my texts. I am trying to develop a method like this for days now without success:
func ConvertText(inputText: String) -> NSAttributedString {
// here comes the conversion to a representation with Helvetica 14pt and Helvetica-Bold 14pt including line feeds.
}
My final goal is to display the text in an UITextView's attributedText.
Being kinda new to Swift and iOS without knowing Objective-C I found its very difficult to do String manipulations as they are quite different and complex and all examples are in Objective-C. What makes it even harder is that most API methods are not available or different in Swift than in Objective-C...
Here is what I tried so far for the method body with the help of a lot of other posts here:
var test = inputText.dataUsingEncoding(NSUnicodeStringEncoding, allowLossyConversion: true)!
attrStr = NSAttributedString(
data: test,
options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
documentAttributes: nil,
error: nil)!
return attrStr
The main issues here are that \n isn't converted and the font is very small and different.
Next I tried to manually bold a part of a text. It seem to work like that:
var newText = NSMutableAttributedString(string: inputText)
newText.addAttribute(NSFontAttributeName, value: UIFont(name: "Helvetica-Bold", size: 14.0)!, range: NSRange(location:2,length:4))
Now I tried to search for the attributes in the text, deleting them and use the addAttribute kinda like that
// Start of bold text
var range = newText.rangeOfString("<b>")!
// End of bold text
var range2 = newText.rangeOfString("</b>")!
// replacing attributes
newText = newText.stringByReplacingCharactersInRange(range, withString: "")
newText = newText.stringByReplacingCharactersInRange(range2, withString: "")
// creating a range for bold => error "'String.Index' is not convertible to 'int'"
// how to do such a thing
var boldRange = NSMakeRange(range.startIndex, range2.endIndex -3)
// setting bold
newText.addAttribute(NSFontAttributeName, value: UIFont(name: "Helvetica-Bold", size: 14.0)!, range: boldRange)
This whole range thing is my main issue at the moment as its quite different to a simple position in the string.
This issue is a great example for the lack of (or well hidden) documentation:
The addAttribute wants an NSRange, the rangeOfString seems to deliver a generic Range according to an error message I get - but there is no info about it.
The Search Documentation button in Xcode on rangeOfString() leads to NSString.
Searching in there for rangeOfString()says it returns NSRange. Clicking on that leads to the info of a type alias for _NSRange which in turn has two NSUInteger properties named location and length. Where is the startIndex and endIndex property I see in XCode? Very confusing...
Would be great if you can give me some snippets or hints where I'm wrong here or even the method body as I'm still hoping its not too difficult if you know iOS and Swift well. I'm aiming for iOS 7.1 support but if its way easier with iOS 8 only its fine as well.
Regarding your first method with NSAttributedString:
The \n character in HTML is just ordinary white space. To get a line break you
would have to replace it by <br /> first.
The font attributes can be controlled by a HTML <span>, see Parsing HTML into NSAttributedText - how to set font?.
This gives (now updated for Swift 2):
func convertText(inputText: String) -> NSAttributedString {
var html = inputText
// Replace newline character by HTML line break
while let range = html.rangeOfString("\n") {
html.replaceRange(range, with: "<br />")
}
// Embed in a <span> for font attributes:
html = "<span style=\"font-family: Helvetica; font-size:14pt;\">" + html + "</span>"
let data = html.dataUsingEncoding(NSUnicodeStringEncoding, allowLossyConversion: true)!
let attrStr = try? NSAttributedString(
data: data,
options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
documentAttributes: nil)
return attrStr!
}
Regarding your second method:
There are two different rangeOfString() methods, one for (Swift) String and one
for (Foundation) NSString. The String method returns a Range<String.Index>
and the NSString method returns an NSRange.
Converting between these two is possible but complicated. The reason is that in
a String each "extended grapheme cluster" counts as one character, whereas in
NSString each UTF-16 unit is counted. An extended grapheme cluster can be
one or more UTF-16 unit ("😄" is two UTF-16 units, "🇩🇪" is four).
The addAttribute() method accepts only an NSRange. The easiest method to solve
this problem is to convert the Swift string to NSString and work with NSRange
only. Then your method could look like this:
func convertText(inputText: String) -> NSAttributedString {
let attrString = NSMutableAttributedString(string: inputText)
let boldFont = UIFont(name: "Helvetica-Bold", size: 14.0)!
var r1 = (attrString.string as NSString).rangeOfString("<b>")
while r1.location != NSNotFound {
let r2 = (attrString.string as NSString).rangeOfString("</b>")
if r2.location != NSNotFound && r2.location > r1.location {
let r3 = NSMakeRange(r1.location + r1.length, r2.location - r1.location - r1.length)
attrString.addAttribute(NSFontAttributeName, value: boldFont, range: r3)
attrString.replaceCharactersInRange(r2, withString: "")
attrString.replaceCharactersInRange(r1, withString: "")
} else {
break
}
r1 = (attrString.string as NSString).rangeOfString("<b>")
}
return attrString
}