Skip to content

Commit

Permalink
List node hierarchy (#325)
Browse files Browse the repository at this point in the history
* Added ability to convert LitItem collection from ListParser to hierarchal representation

* Added ability to parse listItems in hierarchical nodes
  • Loading branch information
rajdeep authored Jul 10, 2024
1 parent baee514 commit 7c73a40
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 0 deletions.
52 changes: 52 additions & 0 deletions Proton/Sources/Swift/Core/ListParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ public struct ListItem {
}
}

public class ListItemNode {
public let item: ListItem
public internal(set) var children: [ListItemNode]

init(item: ListItem, children: [ListItemNode]) {
self.item = item
self.children = children
}
}

/// Provides helper function to convert between `NSAttributedString` and `[ListItem]`
public struct ListParser {

Expand Down Expand Up @@ -105,6 +115,48 @@ public struct ListParser {
return items
}

/// Parses NSAttributedString to list items
/// - Parameters:
/// - attributedString: NSAttributedString to convert to list items.
/// - indent: Indentation used in list representation in attributedString. This determines the level of list item.
/// - Returns: Array of list item nodes with hierarchical representation of list
/// - Note: If NSAttributedString passed into the function is non continuous i.e. contains multiple lists, the array will contain items from all the list with the range corresponding to range of text in original attributed string.
public static func parseListHierarchy(attributedString: NSAttributedString, indent: CGFloat = 25) -> [ListItemNode] {
let listItems = parse(attributedString: attributedString, indent: indent).map { $0.listItem }
return createListItemNodes(from: listItems)
}

/// Creates hierarchical representation of `ListItem` from the provided collection based on levels of each of the items
/// - Parameter listItems: ListItems to convert
/// - Returns: Collection of `ListItemNode` with each node having children nodes based on level of individual list items.
public static func createListItemNodes(from listItems: [ListItem]) -> [ListItemNode] {
var result = [ListItemNode]()
var stack: [(node: ListItemNode, level: Int)] = []

for item in listItems {
let newNode = ListItemNode(item: item, children: [])

// Pop from the stack until the current item's parent is found
while let last = stack.last, last.level >= item.level {
stack.removeLast()
}

if stack.last != nil {
// If there's a parent, add this node to its children
stack[stack.count - 1].node.children.append(newNode)
} else {
// If there's no parent, this is a root node
result.append(newNode)
}

// Push the current node onto the stack
stack.append((newNode, item.level))
}

// Since we've been directly modifying the nodes in the stack, the `result` array now contains the fully constructed tree
return result
}

private static func parseList(in attributedString: NSAttributedString, rangeInOriginalString: NSRange, indent: CGFloat, attributeValue: Any?) -> [(range: NSRange, listItem: ListItem)] {
var items = [(range: NSRange, listItem: ListItem)]()

Expand Down
89 changes: 89 additions & 0 deletions Proton/Tests/Core/ListParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,36 @@ class ListParserTests: XCTestCase {
XCTAssertEqual(list[3].listItem.text.string, text2)
}

func testParsesMultiLevelListIntoListNodes() {
let paraStyle1 = NSMutableParagraphStyle.forListLevel(1)
let paraStyle2 = NSMutableParagraphStyle.forListLevel(2)

let text1 = "This is line 1. This is line 1. This is line 1. This is line 1.\n"
let text1a = "Subitem 1 Subitem 1.\n"
let text1b = "SubItem 2 SubItem 2.\n"
let text2 = "This is line 2. This is line 2. This is line 2."

let attributedString = NSMutableAttributedString(string: text1, attributes: [.paragraphStyle: paraStyle1])
attributedString.append(NSAttributedString(string: text1a, attributes: [.paragraphStyle: paraStyle2]))
attributedString.append(NSAttributedString(string: text1b, attributes: [.paragraphStyle: paraStyle2]))
attributedString.append(NSAttributedString(string: text2, attributes: [.paragraphStyle: paraStyle1]))
attributedString.addAttribute(.listItem, value: 1, range: attributedString.fullRange)

let list = ListParser.parse(attributedString: attributedString)
let nodes = ListParser.createListItemNodes(from: list.map { $0.listItem })
XCTAssertEqual(nodes.count, 2)

XCTAssertEqual(nodes[0].item.level, 1)
XCTAssertEqual(nodes[0].children[0].item.level, 2)
XCTAssertEqual(nodes[0].children[1].item.level, 2)
XCTAssertEqual(nodes[1].item.level, 1)

XCTAssertEqual(nodes[0].item.text.string, String(text1.prefix(text1.count - 1)))
XCTAssertEqual(nodes[0].children[0].item.text.string, String(text1a.prefix(text1a.count - 1)))
XCTAssertEqual(nodes[0].children[1].item.text.string, String(text1b.prefix(text1b.count - 1)))
XCTAssertEqual(nodes[1].item.text.string, text2)
}

func testParsesMultiLevelRepeatingList() {
let levels = 3
let paraStyles = (1...levels).map { NSMutableParagraphStyle.forListLevel($0) }
Expand All @@ -123,6 +153,47 @@ class ListParserTests: XCTestCase {
attributedString.append(NSAttributedString(string: text, attributes: [.paragraphStyle: style]))
}

attributedString.addAttribute(.listItem, value: 1, range: attributedString.fullRange)
let list = ListParser.parse(attributedString: attributedString)
let nodes = ListParser.createListItemNodes(from: list.map { $0.listItem })

XCTAssertEqual(nodes.count, 4)
for node in nodes {
XCTAssertEqual(node.item.text.string, "Text")
XCTAssertEqual(node.item.level, 1)
}

XCTAssertEqual(nodes[0].children.count, 0)

XCTAssertEqual(nodes[1].children.count, 2)
XCTAssertTrue(nodes[1].children.allSatisfy { $0.item.level == 2 })

XCTAssertEqual(nodes[1].children[0].children.count, 0)

XCTAssertEqual(nodes[1].children[1].children.count, 2)
XCTAssertTrue(nodes[1].children[1].children.allSatisfy { $0.item.level == 3 })


XCTAssertEqual(nodes[2].children.count, 0)

XCTAssertEqual(nodes[3].children[0].children.count, 0)

XCTAssertEqual(nodes[3].children[1].children.count, 2)
XCTAssertTrue(nodes[3].children[1].children.allSatisfy { $0.item.level == 3 })
}

func testParsesMultiLevelRepeatingListNodes() {
let levels = 3
let paraStyles = (1...levels).map { NSMutableParagraphStyle.forListLevel($0) }

let text = "Text\n"
let attributedString = NSMutableAttributedString()
for i in 0..<levels * 2 {
let style = paraStyles[i % levels]
attributedString.append(NSAttributedString(string: text, attributes: [.paragraphStyle: style]))
attributedString.append(NSAttributedString(string: text, attributes: [.paragraphStyle: style]))
}

attributedString.addAttribute(.listItem, value: 1, range: attributedString.fullRange)
let list = ListParser.parse(attributedString: attributedString)
XCTAssertEqual(list.count, 12)
Expand Down Expand Up @@ -317,6 +388,24 @@ class ListParserTests: XCTestCase {
XCTAssertEqual(list[1].range, NSRange(location: 80, length: 47))
}

func FIXME_testParsesNonContinuousListNodes() {
let paraStyle = NSMutableParagraphStyle.forListLevel(1)
let line1 = NSAttributedString(string: "This is line 1. This is line 1. This is line 1. This is line 1.", attributes: [.listItem: 1, .paragraphStyle: paraStyle])
let line2 = NSAttributedString(string: "This is line 2.")
let line3 = NSAttributedString(string: "This is line 3. This is line 3. This is line 3.", attributes: [.listItem: 1, .paragraphStyle: paraStyle])

let text = NSMutableAttributedString(attributedString: line1)
text.append(NSAttributedString(string: "\n"))
text.append(line2)
text.append(NSAttributedString(string: "\n"))
text.append( line3)

let list = ListParser.parse(attributedString: text)
let listNodes = ListParser.parseListHierarchy(attributedString: text)
XCTFail()
}


func testFullCircleWithSameAttributeValue() {
let listItems1 = [
ListItem(text: NSAttributedString(string: "One"), level: 1, attributeValue: 1),
Expand Down

0 comments on commit 7c73a40

Please sign in to comment.