How to find and underline all once and twice used keywords in a NSString array

Viewed 89

I have an array of strings. I need a way to find all keywords (once or twice used) and underline them. At the moment, I'm converting them to NSMutableAttributedString which is working, but that just displays the text. Is there a way to write a for loop or something that would find all the ranges of keywords and apply underline attribute to them? Am I doing it all wrong? I'm teaching myself Swift by writing an app instead of taking a course... probably not very smart. Thank you for your help!

I understand that having the client prosses the strings for each keyword each time it is run is very ineffieciet. The strings need to be easily updated, so (for now) it's faster to prosses each time.

Code (full repository ):

(Note: all strings have been removed; the first arrays have 20-40 strings in them)

//
//  TextData.swift
//  QuizBox
//
//  Created by Me on 2/27/22.
//


import Foundation


let  iCor1R = [] as [String]
let  iCor2R = [] as [String]
let  iCor3R = [] as [String]
let  iCor4R = [] as [String]

let iCorR = [
    iCor1R,
    iCor2R,
    iCor3R,
    iCor4R
]


var iCor1T = iCor1R.map { NSMutableAttributedString(string: $0) }
var iCor2T = iCor2R.map { NSMutableAttributedString(string: $0) }
var iCor3T = iCor3R.map { NSMutableAttributedString(string: $0) }
var iCor4T = iCor4R.map { NSMutableAttributedString(string: $0) }


var text = [] as [NSMutableAttributedString]
var allWords = [""]
var onceUsed = [""]
var twiceUsed = [""]


public func generateKeywords() {
    if (allWords == [""]) { // if this function has not been run yet
        for i in Range(0...ICorTEXT.count-1) { // create a combined array with the contents of all sub-arrays
            text.append(contentsOf: ICorTEXT[i])
        }
        for i in text  {
            for j in i.string.components(separatedBy: " ") { // create an array of each individual word in the combined array
                allWords.append(j)
            }
        }
        for i in allWords {
            if(allWords.filter{$0 == i}.count==1) { // find all words that occur only once
                onceUsed.append(i)
            }
            if(allWords.filter{$0 == i}.count==2 && twiceUsed.filter{$0 == i}.count==0) { // find all words that occur only twice
                twiceUsed.append(i)
            }
        }

        for c in Range(0...ICorTEXT.count-1) { //for each chapter
            for v in Range(0...ICorTEXT[c].count-1) { //for each verse
                for w in (ICorTEXT[c][v].string) { // for each word
                    //Maybe like this??
                }
            }
        }
        
//        for i in onceUsed {
//            for c in Range(0...ICorTEXT.count-1) {
//                for v in ICorTEXT[c] {
//                    if let range = v.range(of: i) {
//                        //v.addAttributes(.font, range: range)
//                    } else {
//
//                    }
//                }
//            }
//        }
    }
}



var ICorTEXT = [
    iCor1T,
    iCor2T,
    iCor3T,
    iCor4T
] as [[NSMutableAttributedString]]

New Code:

import Foundation
import SwiftUI

struct MatchProperty
{
    // Stores the index of which array the word
    // was encountered
    var index: Int
    
    // Stores the range of the word in the string
    var range: NSRange
}

extension Array where Element: NSMutableAttributedString
{
    /// Returns a new attributed string by concatenating the elements of the sequence, adding the given separator between each element.
    /// - parameters:
    ///     - separator: A string to insert between each of the elements in this sequence. The default separator is an empty string.
    func joined(separator: NSMutableAttributedString = NSMutableAttributedString(string: "")) -> NSMutableAttributedString
    {
        var isFirst = true
        
        return self.reduce(NSMutableAttributedString())
        {
            (r, e) in
            
            if isFirst
            {
                isFirst = false
            }
            else
            {
                r.append(separator)
            }
            
            r.append(e)
            return r
        }
    }
    
    /// Returns a new attributed string by concatenating the elements of the sequence, adding the given separator between each element.
    /// - parameters:
    ///     - separator: A string to insert between each of the elements in this sequence. The default separator is an empty string.
    func joined(separator: String = "") -> NSMutableAttributedString
    {
        return joined(separator: NSMutableAttributedString(string: separator))
    }
}


// Original data
let iCor1R = ["Lest any should say that I had baptized in mine own name.",
               "Grace be unto you, and peace, from God our Father, and from the.",
               "Even as the testimony of Christ was confirmed in you:"]

let iCor2R = ["And‭ I‭ was‭‭ with‭ you‭ in‭ weakness‭, and‭ in‭ fear‭, and‭ in‭ much‭ trembling‭.",
               "But‭ he that is spiritual‭ judgeth‭‭‭ all things‭, yet‭ God himself‭ is judged‭‭ of‭ no man‭.",]

let iCor3R = ["‭I‭ have planted‭‭, Apollos‭ watered‭‭; but‭ God gave the increase‭‭.",
              "‭Therefore‭ let‭‭ no man‭ glory‭‭ in‭ men‭. For‭ all things‭ are‭‭ yours‭;",
              "‭And‭ ye‭ are Christ’s‭; and‭ Christ‭ [is] God’s‭."]

var iCorR: [[String]] = []

var ICorTEXT: [[NSMutableAttributedString]] = []

// Hash to store words and where they occurred
// This assumes you want to count the occurrences in all your string arrays
// If you want to count it for each string array, initialize this inside the
// loop
var wordOccurrences: [String: [MatchProperty]] = [:]

/// Stores the array index and position a word belongs to
private func storeWordPositions()
{
    iCorR = [
        iCor1R,
        iCor2R,
        iCor3R
    ]
    
    for (iCorRIndex, strings) in iCorR.enumerated()
    {
        // Loop through the string in the strings array
        for (sentenceIndex, string) in strings.enumerated()
        {
            // Loop through all the words in each string of the string array
            string.enumerateSubstrings(in: string.startIndex...,
                                       options: .byWords)
            { [weak self] word, substringRange, _, _ in
                
                // Lower case the word
                if let word = word?.lowercased()
                {
                    // Check if we have come across the word before or not
                    // If we have, retrieve the match property for the word
                    // If not, we will initialize a new array to store the match
                    // properties of the first occurrence
                    var matchesInfo = self?.wordOccurrences[word, default: []]
                    
                    // Create a match location object with the index of where
                    // the word occurs in the strings array along with its range
                    let matchProperty = MatchProperty(iCorRIndex: iCorRIndex,
                                                      sentenceIndex: sentenceIndex,
                                                      range: NSRange(substringRange,
                                                                     in: string))
                    
                    // Store the match
                    matchesInfo?.append(matchProperty)
                    self?.wordOccurrences[word] = matchesInfo
                }
            }
        }
    }
}

/// Underlines the words that occur a specified number of times
private func underlineWords(thatOccur occur: Int)
{
    // This will be used to store the updated versions of the strings
    // with the underlines
    var updatedStrings
        = iCorR.map{ $0.map { NSMutableAttributedString(string: $0) } }
    
    // Optimize by filtering only the words that need processing
    let occurrences = wordOccurrences.filter { return $1.count == occur }

    // Loop through the occurrences of words
    for occurrence in occurrences.values
    {
        // Check which words occurred twice
        if occurrence.count == occur
        {
            // Iterate over the occurrences
            for matchProperty in occurrence
            {
                // Retrieve the attributed version of the string
                let attributedString
                    = updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                
                // Underline words that have occurred twice
                attributedString.addAttribute(.underlineStyle,
                                              value: NSUnderlineStyle.double.rawValue,
                                              range: matchProperty.range)
                
                // Add yellow background as well (optional, just to see easily)
                attributedString.addAttribute(.backgroundColor,
                                              value: UIColor.yellow,
                                              range: matchProperty.range)
                
                // Update the attributed string data
                updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                    = attributedString
            }
        }
    }
    
    ICorTEXT = updatedStrings
}

/// Update the UI
private func updateLabel()
{
    // I am joining all the updated versions of the original string
    // This is not needed, I am just using it to display the original
    // strings but with their updates
    // REF: https://stackoverflow.com/a/48583402/1619193
    let attributedString = ICorTEXT.flatMap{ $0 }.joined(separator: "\n")

    // Set the attributed text where you want
    let label = UILabel()
    label.attributedText = attributedString
}



storeWordPositions()
underlineWords(thatOccur: 3)
updateLabel()

Errors:

error: MyPlayground.playground:90:21: error: cannot find 'self' in scope
            { [weak self] word, substringRange, _, _ in
                    ^~~~

error: MyPlayground.playground:139:52: error: value of type 'MatchProperty' has no member 'iCorRIndex'
                    = updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                                     ~~~~~~~~~~~~~ ^~~~~~~~~~

error: MyPlayground.playground:139:78: error: value of type 'MatchProperty' has no member 'sentenceIndex'
                    = updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                                                               ~~~~~~~~~~~~~ ^~~~~~~~~~~~~

error: MyPlayground.playground:142:48: error: cannot infer contextual base in reference to member 'underlineStyle'
                attributedString.addAttribute(.underlineStyle,
                                              ~^~~~~~~~~~~~~~

error: MyPlayground.playground:147:48: error: cannot infer contextual base in reference to member 'backgroundColor'
                attributedString.addAttribute(.backgroundColor,
                                              ~^~~~~~~~~~~~~~~

error: MyPlayground.playground:152:46: error: value of type 'MatchProperty' has no member 'iCorRIndex'
                updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                               ~~~~~~~~~~~~~ ^~~~~~~~~~

error: MyPlayground.playground:152:72: error: value of type 'MatchProperty' has no member 'sentenceIndex'
                updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                                                         ~~~~~~~~~~~~~ ^~~~~~~~~~~~~

1 Answers

Here is one way of doing it

  1. Join all the strings from the different arrays into one long string
  2. Initialize an empty dictionary to store words encountered and their locations in the string. This will serve as a hash table so we can easily count the occurrences of unique words
  3. Loop through the words in the string storing the locations of the words in the hash table
  4. Loop through all the words in the hash table and check the occurrences of the word in the hash table underlining the words as per your logic

For example:

I have an array of strings.

// Array of strings
let strings = ["This will occur twice",
               "Will also occurs two times",
               "But where is something else",
               "Other things happen once this time"]

Let's say I want to underline all the words that occur twice

// Join all the strings
let string = strings.joined(separator: ". ")

// Hash to store words and where they occurred
var wordOccurrences: [String: [NSRange]] = [:]

// Loop through all the words
string.enumerateSubstrings(in: string.startIndex...,
                           options: .byWords)
{ word, substringRange, _, _ in
    
    // Lower case the word
    if let word = word?.lowercased()
    {
        // Store all the locations of the word
        var wordRanges = wordOccurrences[word, default: []]
        wordRanges.append(NSRange(substringRange, in: string))
        wordOccurrences[word] = wordRanges
    }
}

// Create an attributed string
let attributedString = NSMutableAttributedString(string: string)

// Loop through the occurrences of words
for occurrence in wordOccurrences.values
{
    // Check which words occurred twice
    if occurrence.count == 2
    {
        for range in occurrence
        {
            // Underline words that have occurred twice
            attributedString.addAttribute(.underlineStyle,
                                          value: NSUnderlineStyle.double.rawValue,
                                          range: range)
        }
    }
}

// Set the attributed text where you want
label.attributedText = attributedString

This gives me the following result:

Underline occurrences of keyword swift iOS NSMutableAttributedString NSAttributedString

Update

Based on Jon's (OP) comments:

How do I split it back into the original array? I need the original array with the added underlines.

In order to that, we need to keep track of the word count as before using the hash table. However, the range will not be enough as we needed the added information of which string in the original array the word belongs to.

In order to do that, I created a struct MatchProperty to store this information:

struct MatchProperty
{
    // Stores the index of which array the word
    // was encountered
    var index: Int
    
    // Stores the range of the word in the string
    var range: NSRange
}

Then I made some small updates to the logic

// Array of strings
let strings = ["This will occur twice",
               "Will also occurs two times",
               "But where is something else",
               "Other things happen once this time"]

// Hash to store words and where they occurred
var wordOccurrences: [String: [MatchProperty]] = [:]

// Loop through the string in the strings array
for (index, string) in strings.enumerated()
{
    // Loop through all the words in each string of the string array
    string.enumerateSubstrings(in: string.startIndex...,
                               options: .byWords)
    { word, substringRange, _, _ in
        
        // Lower case the word
        if let word = word?.lowercased()
        {
            // Check if we have come across the word before or not
            // If we have, retrieve the match property for the word
            // If not, we will initialize a new array to store the match
            // properties of the first occurrence
            var matchesInfo = wordOccurrences[word, default: []]
            
            // Create a match location object with the index of where
            // the word occurs in the strings array along with its range
            let matchProperty = MatchProperty(index: index,
                                              range: NSRange(substringRange,
                                                             in: string))
            
            // Store the match
            matchesInfo.append(matchProperty)
            wordOccurrences[word] = matchesInfo
        }
    }
}

// This will be used to store the updated versions of the strings
// with the underlines
var updatedStrings
    = strings.map { return NSMutableAttributedString(string: $0) }

// Loop through the occurrences of words
for occurrence in wordOccurrences.values
{
    // Check which words occurred twice
    if occurrence.count == 2
    {
        // Iterate over the occurrences
        for matchProperty in occurrence
        {
            // Retrieve the attributed version of the string
            let attributedString = updatedStrings[matchProperty.index]
            
            // Underline words that have occurred twice
            attributedString.addAttribute(.underlineStyle,
                                          value: NSUnderlineStyle.double.rawValue,
                                          range: matchProperty.range)
            
            // Update the attributed string data
            updatedStrings[matchProperty.index] = attributedString
        }
    }
}

// I am joining all the updated versions of the original string
// This is not needed, I am just using it to display the original
// strings but with their updates
// REF: https://stackoverflow.com/a/48583402/1619193
let attributedString = updatedStrings.joined(separator: "\n")

// Set the attributed text where you want
label.attributedText = attributedString

The function joined is not something that comes by default for arrays of NSMutableAttributedStrings, so I used this answer to be able to do that

The code for that is as follows, however this is not required for your solution, this is just so i could display to a label and for completeness:

extension Array where Element: NSMutableAttributedString
{
    /// Returns a new attributed string by concatenating the elements of the sequence, adding the given separator between each element.
    /// - parameters:
    ///     - separator: A string to insert between each of the elements in this sequence. The default separator is an empty string.
    func joined(separator: NSMutableAttributedString = NSMutableAttributedString(string: "")) -> NSMutableAttributedString
    {
        var isFirst = true
        
        return self.reduce(NSMutableAttributedString())
        {
            (r, e) in
            
            if isFirst
            {
                isFirst = false
            }
            else
            {
                r.append(separator)
            }
            
            r.append(e)
            return r
        }
    }
    
    /// Returns a new attributed string by concatenating the elements of the sequence, adding the given separator between each element.
    /// - parameters:
    ///     - separator: A string to insert between each of the elements in this sequence. The default separator is an empty string.
    func joined(separator: String = "") -> NSMutableAttributedString
    {
        return joined(separator: NSMutableAttributedString(string: separator))
    }
}

The final result is the updated attributes added to the original strings.

NSAttributedString underline occurrences of string swift iOS

Update 2

I need the output in the form of [[NSMutableAttributedString]]

I think you will have to make some tweaks to the above to make it work for your end result.

Things to tweak are the logic of the loops, initialization of the final array etc.

You will also need to store one more added bit of information for each word, which iCorR array it belongs to.

So I have modified the MatchProperty struct a bit:

struct MatchProperty
{
    // Stores the index of the iCor array the current
    // word belongs to
    var iCorRIndex: Int
    
    // Stores the index of the sentence in the which the word
    // was encountered in the current iCor array
    // Here I refer to each item in an iCor array as a sentence
    var sentenceIndex: Int
    
    // Stores the range of the word in the string
    var range: NSRange
}

Then here is an example using your own data:

// Original data
let iCor1R = ["Lest any should say that I had baptized in mine own name.",
               "Grace be unto you, and peace, from God our Father, and from the.",
               "Even as the testimony of Christ was confirmed in you:"]

let iCor2R = ["And‭ I‭ was‭‭ with‭ you‭ in‭ weakness‭, and‭ in‭ fear‭, and‭ in‭ much‭ trembling‭.",
               "But‭ he that is spiritual‭ judgeth‭‭‭ all things‭, yet‭ God himself‭ is judged‭‭ of‭ no man‭.",]

let iCor3R = ["‭I‭ have planted‭‭, Apollos‭ watered‭‭; but‭ God gave the increase‭‭.",
              "‭Therefore‭ let‭‭ no man‭ glory‭‭ in‭ men‭. For‭ all things‭ are‭‭ yours‭;",
              "‭And‭ ye‭ are Christ’s‭; and‭ Christ‭ [is] God’s‭."]

var iCorR: [[String]] = []

var ICorTEXT: [[NSMutableAttributedString]] = []

// Hash to store words and where they occurred
// This assumes you want to count the occurrences in all your string arrays
// If you want to count it for each string array, initialize this inside the
// loop
var wordOccurrences: [String: [MatchProperty]] = [:]

Then here are some minor updates made to work with your dataset:

/// Stores the array index and position a word belongs to
private func storeWordPositions()
{
    iCorR = [
        iCor1R,
        iCor2R,
        iCor3R
    ]
    
    for (iCorRIndex, strings) in iCorR.enumerated()
    {
        // Loop through the string in the strings array
        for (sentenceIndex, string) in strings.enumerated()
        {
            // Loop through all the words in each string of the string array
            string.enumerateSubstrings(in: string.startIndex...,
                                       options: .byWords)
            { [weak self] word, substringRange, _, _ in
                
                // Lower case the word
                if let word = word?.lowercased()
                {
                    // Check if we have come across the word before or not
                    // If we have, retrieve the match property for the word
                    // If not, we will initialize a new array to store the match
                    // properties of the first occurrence
                    var matchesInfo = self?.wordOccurrences[word, default: []]
                    
                    // Create a match location object with the index of where
                    // the word occurs in the strings array along with its range
                    let matchProperty = MatchProperty(iCorRIndex: iCorRIndex,
                                                      sentenceIndex: sentenceIndex,
                                                      range: NSRange(substringRange,
                                                                     in: string))
                    
                    // Store the match
                    matchesInfo?.append(matchProperty)
                    self?.wordOccurrences[word] = matchesInfo
                }
            }
        }
    }
}

/// Underlines the words that occur a specified number of times
private func underlineWords(thatOccur occur: Int)
{
    // This will be used to store the updated versions of the strings
    // with the underlines
    var updatedStrings
        = iCorR.map{ $0.map { NSMutableAttributedString(string: $0) } }
    
    // Optimize by filtering only the words that need processing
    let occurrences = wordOccurrences.filter { return $1.count == occur }

    // Loop through the occurrences of words
    for occurrence in occurrences.values
    {
        // Check which words occurred twice
        if occurrence.count == occur
        {
            // Iterate over the occurrences
            for matchProperty in occurrence
            {
                // Retrieve the attributed version of the string
                let attributedString
                    = updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                
                // Underline words that have occurred twice
                attributedString.addAttribute(.underlineStyle,
                                              value: NSUnderlineStyle.double.rawValue,
                                              range: matchProperty.range)
                
                // Add yellow background as well (optional, just to see easily)
                attributedString.addAttribute(.backgroundColor,
                                              value: UIColor.yellow,
                                              range: matchProperty.range)
                
                // Update the attributed string data
                updatedStrings[matchProperty.iCorRIndex][matchProperty.sentenceIndex]
                    = attributedString
            }
        }
    }
    
    ICorTEXT = updatedStrings
}

/// Update the UI
private func updateLabel()
{
    // I am joining all the updated versions of the original string
    // This is not needed, I am just using it to display the original
    // strings but with their updates
    // REF: https://stackoverflow.com/a/48583402/1619193
    let attributedString = ICorTEXT.flatMap{ $0 }.joined(separator: "\n")

    // Set the attributed text where you want
    label.attributedText = attributedString
}

And then you use these functions:

storeWordPositions()
underlineWords(thatOccur: 3)
updateLabel()

To give you:

Underline occurrences of word string count in iOS Swift NSMutableAttributedString hash table

Final note, please review your text as there are some hidden unicode characters which impact counting.

Related