Vlad
Vlad

Reputation: 6732

iOS: String. Get line and column number from absolute position and vice versa

Say I have a text which contains mixed CR, LF and CRLF newline separators.

Like this: "\n \n Lorem \r Ipsum \n is \r\n simply \n dummy \r\n text of \n the printing \r and typesetting industry. \n \n".

I'm loading this text into simple text editor (NSTextView/UITextView). Visually newline separators looks the same; just a new line.

I can navigate through text in simple text editor, select text, cut, copy, paste, ...

Question: How can I get line and column number from absolute character location (i.e selection NSRange)? And also, how can I get absolute character location from known line and column number?

Thanks!


UPDATE 1:

Sample code of current solution. It is calculates line and column number from absolute character location and vice versa. But it is not recalculates mappings on text change.

struct TextString {

   struct Cursor {
      let line: Int
      let column: Int
   }

   struct Mapping {
      let lineNumber: Int
      let lineLength: Int
      let absolutePosition: Int

      fileprivate var absoluteStart: Int {
         return absolutePosition - lineLength
      }
   }

   let string: String
   private (set) var mappings: [Mapping] = []

   init(string: String) {
      self.string = string
      mappings = setupMappings()
   }
}

extension TextString {

   func cursor(from position: Int) -> Cursor? {
      guard position > 0 else {
         return nil
      }
      guard let mapping = mappings.first(where: { $0.absolutePosition >= position && $0.absoluteStart <= position }) else {
         return nil
      }
      let result = Cursor(line: mapping.lineNumber, column: position - mapping.absoluteStart)
      return result
   }

   func position(from cursor: Cursor) -> Int? {
      guard let line = mappings.element(at: cursor.line - 1) else {
         return nil
      }
      guard line.lineLength >= cursor.column else {
         return nil
      }
      let result = line.absoluteStart + cursor.column
      return result
   }
}

extension TextString {

   private func setupMappings() -> [Mapping] {
      var mappings: [Mapping] = []
      var line = 1
      var previousAbsolutePosition = 0
      var delta = 0
      let scanner = Scanner(string: string)
      scanner.charactersToBeSkipped = nil
      while !scanner.isAtEnd {
         if scanner.scanUpToCharacters(from: .newlines) != nil {
            let charactersLocation = scanner.scanLocation - delta
            if let newLines = scanner.scanCharacters(from: .newlines) {
               for index in 0..<newLines.count {
                  let absolutePosition = charactersLocation + 1 + index // `+1` is newLine itself
                  mappings.append(Mapping(lineNumber: line, lineLength: absolutePosition - previousAbsolutePosition,
                                          absolutePosition: absolutePosition))
                  previousAbsolutePosition = absolutePosition
                  line += 1
               }
               delta = scanner.scanLocation - previousAbsolutePosition
            } else {
               // Only happens when we at last line withot newline.
               let absolutePosition = charactersLocation
               mappings.append(Mapping(lineNumber: line, lineLength: absolutePosition - previousAbsolutePosition,
                                       absolutePosition: absolutePosition))
               line += 1
               previousAbsolutePosition = charactersLocation
            }
         } else if let newLines = scanner.scanCharacters(from: .newlines) { // Text begins with new lines.
            for index in 0..<newLines.count {
               let absolutePosition = 1 + index // `+1` is newLine itself
               mappings.append(Mapping(lineNumber: line, lineLength: absolutePosition - previousAbsolutePosition,
                                       absolutePosition: absolutePosition))
               previousAbsolutePosition = absolutePosition
               line += 1
            }
            delta = scanner.scanLocation - previousAbsolutePosition
         }
      }
      assert(previousAbsolutePosition == string.count)
      return mappings
   }
}

UPDATE 2: RegEx version.

private func setupMappingsUsingRegex() throws -> [Mapping] {
   if string.isEmpty {
      return []
   }
   var mappings: [Mapping] = []
   let regex = try NSRegularExpression(pattern: "(\\r\\n)|(\\n)|(\\r)")
   let matches = regex.matches(in: string, range: NSRange(location: 0, length: string.unicodeScalars.count))
   var line = 1
   var previousAbsolutePosition = 0
   var delta = 0

   // String without any newline.
   if matches.isEmpty {
      let mapping = Mapping(lineNumber: 1, lineLength: string.count, absolutePosition: string.count)
      mappings.append(mapping)
      return mappings
   }

   for match in matches {
      let absolutePosition = match.range.location - delta + 1
      let mapping = Mapping(lineNumber: line, lineLength: absolutePosition - previousAbsolutePosition,
                            absolutePosition: absolutePosition)
      mappings.append(mapping)
      delta += match.range.length - 1
      previousAbsolutePosition = absolutePosition
      line += 1
   }

   // Rest of the string without newline at the end.
   if previousAbsolutePosition < string.count {
      let mapping = Mapping(lineNumber: line, lineLength: string.count - previousAbsolutePosition,
                            absolutePosition: string.count)
      mappings.append(mapping)
      previousAbsolutePosition = string.count
   }
   assert(previousAbsolutePosition == string.count)
   return mappings
}

Performance: 22400 characters (200 lines) analysed 1000 times.

Upvotes: 0

Views: 1244

Answers (2)

gabriellanata
gabriellanata

Reputation: 4336

This worked for me to get the line number:

let content: String = <YourString>
let selectionRange: NSRange = <YourTextRange>
let regex = try! NSRegularExpression(pattern: "\n", options: [])
let lineNumber = regex.numberOfMatches(in: content, options: [], range: NSMakeRange(0, nsRange.location)) + 1

You can customize the regex to match any kind of newline character you want.

To get the column number you can get the line range and then subtract the start of it from the selection range:

let lineRange = content.lineRange(for: selectionRange.location)
let column = selectionRange.location - lineRange.location

Upvotes: 2

Fangming
Fangming

Reputation: 25261

I suggest you to separate your string by using regex. Say you want to split a substring if you see \n, \r and \r\n, the regex will be something like

var content: String = <Your text here>
let regex = try! NSRegularExpression(pattern: "(\\n)|(\\r)|(\\r\\n)")
let matchs = regex.matches(in: content, range: NSRange(location: 0, length: content.count)).map{(content as NSString).substring(with: $0.range)}

Then you can loop within the matched results and get index & range, etc

Upvotes: 2

Related Questions