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
andcolumn
number - simple means cursor location.line
andcolumn
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