From 7c73a40a2eb7d8551b7a006c1e4b8971ad2f1633 Mon Sep 17 00:00:00 2001 From: Rajdeep Kwatra Date: Wed, 10 Jul 2024 16:37:42 +1000 Subject: [PATCH] List node hierarchy (#325) * Added ability to convert LitItem collection from ListParser to hierarchal representation * Added ability to parse listItems in hierarchical nodes --- Proton/Sources/Swift/Core/ListParser.swift | 52 +++++++++++++ Proton/Tests/Core/ListParserTests.swift | 89 ++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/Proton/Sources/Swift/Core/ListParser.swift b/Proton/Sources/Swift/Core/ListParser.swift index a4a11c48..8268f94e 100644 --- a/Proton/Sources/Swift/Core/ListParser.swift +++ b/Proton/Sources/Swift/Core/ListParser.swift @@ -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 { @@ -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)]() diff --git a/Proton/Tests/Core/ListParserTests.swift b/Proton/Tests/Core/ListParserTests.swift index 6bf7e3bc..1f0f616f 100644 --- a/Proton/Tests/Core/ListParserTests.swift +++ b/Proton/Tests/Core/ListParserTests.swift @@ -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) } @@ -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..