TextKit 2: Applying ParagraphStyle in custom NSTextParagraph leads to cursor position being off - swift

In a NSTextContentStorageDelegate I use textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? to serve custom NSTextParagraphs. In these I set a paragraph style to the attributed string. The head indent of this paragraph style equals the amount of whitespace at the beginning of the string. This leads to an aligned indent when the line is wrapped in the text view.
This works. However, in some cases (not always) the caret will not move to the end of the wrapped line. It is stuck before and does not reflect the correct insertion point, a bit like so:
xxx xx xx
xxx xx Ixx <- does not move to the last characters
I add the code below. Does anybody have an idea why this may happen? All help is greatly appreciated.
This is the code:
extension HighlightTextEditor.Coordinator : NSTextContentStorageDelegate{
func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? {
var paragraphWithDisplayAttributes: NSTextParagraph? = nil
let textWithDisplayAttributes = textContentStorage.textStorage!.attributedSubstring(from: range)
var finalString: NSMutableAttributedString? = nil
if parent.lineBreakStrategy != .none{
var indentLength: CGFloat = 0
//find the first index which is not a whitespace
if let endIndex = textWithDisplayAttributes.string.firstIndex(where: {!$0.isWhitespace}){
if endIndex != textWithDisplayAttributes.string.startIndex{
//calculate a range from start to first non-whitespace character and convert to NSRange
let range: Range = textWithDisplayAttributes.string.startIndex..<endIndex
let convertedRange = NSRange(range, in: textWithDisplayAttributes.string)
//get a substring with only the whitespace characters
var measureString: NSAttributedString?
if parent.lineBreakStrategy == .aligned{
measureString = textWithDisplayAttributes.attributedSubstring(from: convertedRange)
} else if parent.lineBreakStrategy == .indented{
measureString = textWithDisplayAttributes.attributedSubstring(from: convertedRange) + NSAttributedString(string: " ")
}
//get the size of the substring and calcualte the index lengths depending on the strategy
indentLength = measureString?.size().width ?? 0
//create the paragraphStyle and set the headIndent either to the length or 0, if not calculated
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.headIndent = indentLength
//add the paragraphstyle to textWithDisplayAttributes
finalString = NSMutableAttributedString(attributedString: textWithDisplayAttributes)
if let finalString = finalString{
finalString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: finalString.length))
//create the new NSTextPara with the final string
paragraphWithDisplayAttributes = NSTextParagraph(attributedString: finalString)
}
}
}
}
return paragraphWithDisplayAttributes
}

Related

Swift: NSMutableAttributedString generated from PDF. Can it be parsed into a Dictionary?

I have several large PDF docs (70-200, pages each). The PDFs themselves are generated from HTML pages (I can't get the source code of the HTML pages which is why I am working with the PDFs). Anyway, what I want to do is parse the PDF into separate pages based on the converted H1 tag attribute. When I print out the PDF I get this:
Seller Tag (AST)
{
NSBaselineOffset = 0;
NSColor = "Device RGB colorspace 0.94118 0.32549 0.29804 1";
NSFont = "\"Helvetica 8.00 pt. P [] (0x7ff0f262e590) fobj=0x7ff0f4339680, spc=2.22\"";
}Table of Contents
{
NSBaselineOffset = 0;
NSColor = "Device RGB colorspace 0.94118 0.32549 0.29804 1";
NSFont = "\"Helvetica 34.00 pt. P [] (0x7ff0f262e590) fobj=0x7ff0f432f940, spc=9.45\"";
}...
which looks like a bunch of attributes contained in a Dictionary. But when I run this code:
let strContent = myAppManager.pdfToText(fromPDF:pdfDirPath.absoluteString + "/" + thisFile)
let strPDF:NSAttributedString = strContent
let strNSPDF = strPDF.string as NSString
let rangeOfString = NSMakeRange(0, strNSPDF.length)
let arrAttributes = strPDF.attributes(at: 0, longestEffectiveRange: nil, in: rangeOfString)
print(arrAttributes)
I get this output
[__C.NSAttributedStringKey(_rawValue: NSColor): Device RGB colorspace 0.94118 0.32549 0.29804 1, __C.NSAttributedStringKey(_rawValue: NSBaselineOffset): 0, __C.NSAttributedStringKey(_rawValue: NSFont): "Helvetica 8.00 pt. P [] (0x7ff0f441d490) fobj=0x7ff0f4339680, spc=2.22"]
I was kind of expecting a high number, like 1000 or more entries, not 1.
So snooping around, I know the H1 HTML tag gets converted to this:
Table of Contents
{
NSBaselineOffset = 0;
NSColor = "Device RGB colorspace 0.94118 0.32549 0.29804 1";
NSFont = "\"Helvetica 34.00 pt. P [] (0x7ff0f262e590) fobj=0x7ff0f432f940, spc=9.45\"";
}
So what I am looking to do is delimit the converted H1s so I can get the content between as a page and do stuff with it. Any ideas or suggestions would be appreciated.
Quickly done, assuming you have:
someText[HEADER1]someText1[HEADER2]someText2[HEADER3]someText3...
Where [HEADERN] have the same attributes (and you know them) but not the same as someTextN.
We want in the end, and array of:
struct Page: CustomStringConvertible {
let title: NSAttributedString? //Tha's be the h1 tag content
let content: NSAttributedString?
var description: String {
return "Title: \(title?.string ?? "") - content: \(content?.string ?? "")"
}
}
Initial sample:
let htmlString = "<b>Title 1</b> Text for part one.\n <b>Title 2</b> Text for part two<b>Title 3</b>Text for part three"
let attributedString = try! NSAttributedString(data: Data(htmlString.utf8),
options: [.documentType : NSAttributedString.DocumentType.html],
documentAttributes: nil)
With:
let headerAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12)]
print("headerAttributes: \(headerAttributes)")
func headerOneAttributes(_ headerAttributes: [NSAttributedString.Key: Any], matches attributes: [NSAttributedString.Key: Any]?) -> Bool {
guard let attributes = attributes else { return false }
guard let attributesFont = attributes[.font] as? NSFont, let headerFont = headerAttributes[.font] as? NSFont else {
return false
}
return attributesFont.fontDescriptor.symbolicTraits == NSFontDescriptor.SymbolicTraits(rawValue: 268435458) //Here fonts arent' equal equal, some work here plus checking on other attributes too and font size?
// Do you own check
// return false
}
We can iterates the attributes to get all the headers ranges:
var headerRanges: [NSRange] = []
attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: []) { attributes, range, stop in
if headerOneAttributes(headerAttributes, matches: attributes) {
headerRanges.append(range)
}
}
With an iteration on the ranges:
var pages: [Page] = []
guard !headerRanges.isEmpty else { return }
//In case the first title doesn't "start at the beginning", we have a "content" with no title at start
if let first = headerRanges.first, first.location > 0 {
pages.append(Page(title: nil, content: attributedString.attributedSubstring(from: first)))
}
// Then we iterate
for (anIndex, aRange) in headerRanges.enumerated() {
print(pages)
let title = attributedString.attributedSubstring(from: aRange)
let subtext: NSAttributedString?
// If there is a "nextRange", then we get the end of subtext from it
if anIndex + 1 <= headerRanges.count - 1 {
let next = headerRanges[anIndex + 1]
let location = aRange.location + aRange.length
let length = next.location - location
subtext = attributedString.attributedSubstring(from: NSRange(location: location, length: length))
} else {
//There is no next => Until the end
let location = aRange.location + aRange.length
let length = attributedString.length - location
subtext = attributedString.attributedSubstring(from: NSRange(location: location, length: length))
}
pages.append(Page(title:title, content: subtext))
}
print(pages)
PS: UIFont/NSFont: ~the same, I tested on a macOS app, not iOS, that's why.
Okay, so #Larme put me on the right track for what I was looking for. Posting the code in hopes it helps someone else. I've tested this on a 77 page document and it worked. I should have noted in the question that I am working on MacOS.
func parsePDF(_ strPDFContent:NSMutableAttributedString) -> Array<Dictionary<String, Any>> {
//some initial setup
let strNSPDF = strPDFContent.string as NSString
var arrDocSet:Array<Dictionary<String, Any>> = []
//get all the page headers
var arrRanges = [NSRange]()
strPDFContent.enumerateAttribute(NSAttributedString.Key.font, in: NSRange(0..<strPDFContent.length), options: .longestEffectiveRangeNotRequired) {
value, range, stop in
if let thisFont = value as? NSFont {
if thisFont.pointSize == 34 {
arrRanges.append(range)
}
}
}
//get the content and store data
for (idx, range) in arrRanges.enumerated() {
//get title
let strTitle = String(strNSPDF.substring(with: range))
var textRange = NSRange(location:0, length:0)
//skip opening junk
if !strTitle.contains("Table of Contents\n") {
if idx < arrRanges.count-1 {
textRange = NSRange(location: range.upperBound, length: arrRanges[idx+1].lowerBound - range.upperBound)
} else if idx == arrRanges.count-1 {
textRange = NSRange(location: range.upperBound, length: strNSPDF.length - range.upperBound)
}
let strContent = String(strNSPDF.substring(with: textRange))
arrDocSet.append(["title":strTitle, "content":strContent, "contentRange":textRange, "titleRange":range])
}
}
print(arrDocSet)
return arrDocSet
}
This will output:
["titleRange": {10001, 27}, "title": "Set up Placements with AST\n", "content": "This page contains a sample web page showing how Xandr\'s seller tag (AST) functions can be implemented in the header and body of a sample client page.\nSee AST API Reference for more details on using ...
...
ready.\nExample\n$sf.ext.status();\n", "title": " SafeFrame API Reference\n", "contentRange": {16930, 9841}
Let me know if there's places I could be more efficient.

how to change format of substring in swift

I get a message from my response like "your bill is: 10.00"
But I need to show in bold the number and only that (everything after the ":"). I know I could use SubString, but don't understand exactly how to split text and correctly format it
my old test:
self.disclaimerLabel.attributedText = String(format: my).htmlAttributedString(withBaseFont: Font.overlineRegular07.uiFont, boldFont: Font.overlineBold02.uiFont, baseColor: Color.blueyGreyTwo.uiColor, boldColor: Color.blueyGreyTwo.uiColor)
How was my built ? If from 2 parts, set attributes to each before joining.
If you get my as a whole, you can access substrings with
let parts = my.split(separator: ":")
parts[1] will be "your bill is"
parts[2] will be "10:00"
The need to add styling to a single word or phrase is so common that it is worth having on hand a method to help you:
extension NSMutableAttributedString {
func apply(attributes: [NSAttributedString.Key: Any], to targetString: String) {
let nsString = self.string as NSString
let range = nsString.range(of: targetString)
guard range.length != 0 else { return }
self.addAttributes(attributes, range: range)
}
}
So then your only problem is discovering the stretch of text that you want to apply the attributes to. If you don't know that it is "10.00" then, as you've been told, you can find out by splitting the string at the colon-plus-space.
You can split your string into char : and then you can change text attributes like :
var str = "your bill is: 10.00"
var splitArray = str.components(separatedBy: ":")
let normalText = NSMutableAttributedString(string: splitArray[0] + ":")
let boldText = splitArray[1]
let boldTextAtr = NSMutableAttributedString(string: boldText, attributes: [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 16.0) ])
normalText.append(boldTextAtr)
let labell = UILabel()
labell.attributedText = normalText
labell.attributedText will print what you exactly want

How to split a NSAttributedString

I imported a NSAttributedString from a rtf-file and now I want to split it at another given String. With the attributedSubstring method you get one attributedSubstring as result, but I want to split it at every part, where the other String appeares, so the result should be an Array of NSAttributedStrings.
Example:
var source = NSAttributedString(string: "I*** code*** with*** swift")
var splitter = "***"
var array = //The method I am looking for
The result should be the following Array(with attributedStrings): [I, code, with, swift]
Following extension method maps the string components using Array.map into [NSAttributedString]
extension NSAttributedString {
func components(separatedBy string: String) -> [NSAttributedString] {
var pos = 0
return self.string.components(separatedBy: string).map {
let range = NSRange(location: pos, length: $0.count)
pos += range.length + string.count
return self.attributedSubstring(from: range)
}
}
}
Usage
let array = NSAttributedString(string: "I*** code*** with*** swift").components(separatedBy: "***")

How to use replacingOccurrences for NSAttributedString in swift?

In a NSAttributed type statement, I want to keep the existing attributed value and give it a new attributed value.
The problem is that replacingOccurrences is only possible for string types, as I want to give a new value every time the word appears in the entire sentence.
If I change NSAttributedString to string type, the attributed value is deleted. I must keep the existing values.
How can I do that?
To get this working,
1. First you need to find the indices of all the duplicate substrings existing in a string. To get that you can use this: https://stackoverflow.com/a/40413665/5716829
extension String {
func indicesOf(string: String) -> [Int] {
var indices = [Int]()
var searchStartIndex = self.startIndex
while searchStartIndex < self.endIndex,
let range = self.range(of: string, range: searchStartIndex..<self.endIndex),
!range.isEmpty
{
let index = distance(from: self.startIndex, to: range.lowerBound)
indices.append(index)
searchStartIndex = range.upperBound
}
return indices
}
}
2. Next you need to apply your desired attribute to substring at each index, i.e.
let str = "The problem is that replacingOccurrences Hello is only possible for string types, as I want to give Hello a new value every time Hello the word appears in the entire sentence Hello."
let indices = str.indicesOf(string: "Hello")
let attrStr = NSMutableAttributedString(string: str, attributes: [.foregroundColor : UIColor.blue])
for index in indices
{
//You can write your own logic to specify the color for each duplicate. I have used some hardcode indices
var color: UIColor
switch index
{
case 41:
color = .orange
case 100:
color = .magenta
case 129:
color = .green
default:
color = .red
}
attrStr.addAttribute(.foregroundColor, value: color, range: NSRange(location: index, length: "Hello".count))
}
Screenshot:
Let me know if you still face any issues. Happy Coding..🙂
You can use replaceCharacters. You can find the range of the substring you want to remove and use it as your range.

Swift. Add attributes to multiple instances

I have arrays containing strings of the text terms to which I want to apply a particular attribute. Here's a code snippit:
static var bold = [String]()
static let boldAttribs = [NSFontAttributeName: UIFont(name: "WorkSans-Medium", size: 19)!]
for term in bold {
atStr.addAttributes(boldAttribs, range: string.rangeOfString(term))
}
This works great for single term or phrase use. But it only applies to the first use of a specific term. Is there a way, without resorting to numerical ranges, to apply the attribute to all instances of the same term? For example, make every use of "animation button" within the same string bold.
Edit: This works.
// `butt2` is [String]() of substrings to attribute
// `term` is String element in array, target of attributes
// `string` is complete NAString from data
// `atStr` is final
for term in butt2 {
var pos = NSRange(location: 0, length: string.length)
while true {
let next = string.rangeOfString(term, options: .LiteralSearch, range: pos)
if next.location == NSNotFound { break }
pos = NSRange(location: next.location+next.length, length: string.length-next.location-next.length)
atStr.addAttributes(butt2Attribs, range: next)
}
}
You don't have to resort to numerical ranges, but you do need to resort to a loop:
// atStr is mutable attributed string
// str is the input string
atStr.beginEditing()
var pos = NSRange(location: 0, length: atStr.length)
while true {
let next = atStr.rangeOfString(target, options: .LiteralSearch, range: pos)
if next.location == NSNotFound {
break
}
atStr.addAttributes(boldAttribs, range: next)
print(next)
pos = NSRange(location: next.location+next.length, length: atStr.length-next.location-next.length)
}
atStr.endEditing()