0

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:

  • line and column number - simple means cursor location.
  • line and column number - has One-based numbering.
  • absolute character location - has Zero-based numbering.

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.

  • RegEx: 5.120s
  • Scanner: 6.603s
Vlad
  • 6,402
  • 1
  • 60
  • 74
  • I'd say that with https://stackoverflow.com/questions/31746223/swift-number-of-occurrences-of-substring-in-string, counting the number of `\r\n` (first), then `\n` and \`r", you should be able to get the info you need from a substring made from "0 to absoluteLocation", and the same way with iteration to get the absoluteLocation? I'm not sure of what defines "lines" and column", but you may want to give an example with let's say the "s" of `simply`. – Larme Nov 09 '17 at 17:18
  • 1
    Having mixed line endings is non-sensical. I believe that the text views only recognize \n. You should normalize the line endings to \n only and then solve your problem. – PhoneyDeveloper Nov 10 '17 at 01:42

2 Answers2

2

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

Fangming
  • 24,551
  • 6
  • 100
  • 90
  • Unfortunately `components(separatedBy: .newlines)` will **eat** 2 invisible characters `\r\n` and rest calculated indexes will be misaligned on number of **eaten** newlines :0 – Vlad Nov 09 '17 at 18:19
  • 1
    @LeoDabus taking into account `unicodeScalars` will work. True. – Vlad Nov 09 '17 at 18:48
2

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
gabriellanata
  • 3,946
  • 2
  • 21
  • 27