import Foundation struct DocumentWordCounts { let words: Int let characters: Int let charactersNoSpaces: Int let paragraphs: Int let selectedWords: Int? init(projection: CRDTProjection, content: NSAttributedString, selection: NSRange) { var whole = "" func visit(_ node: CRDTNode) { if node.type == "text" { whole += node.plainText } else { if !["image", "hard_break"].contains(node.type), !whole.isEmpty { whole += " " } for child in node.children ?? [] { visit(child) } } } for node in projection.children { visit(node) } words = Self.countWords(whole) characters = whole.utf16.count charactersNoSpaces = whole.unicodeScalars.filter { !CharacterSet.whitespacesAndNewlines.contains($0) }.reduce(0) { $0 + $1.utf16.count } paragraphs = projection.children.filter { ["paragraph", "heading", "code_block", "bullet_list", "ordered_list", "blockquote"].contains($0.type) }.count let valid = NSIntersectionRange(selection, NSRange(location: 0, length: content.length)) if valid.length == 0 { selectedWords = nil } else { var selected = "" content.enumerateAttributes(in: valid) { attributes, range, _ in if attributes[.docsMarks] is Data { selected += (content.string as NSString).substring(with: range) } else if attributes[.docsBlock] != nil { // Only the paragraph terminator separates words. Inline // images and hard breaks contribute no text in web counts. for scalar in (content.string as NSString).substring(with: range).unicodeScalars where scalar == "\n" { selected += " " } } } selectedWords = Self.countWords(selected) } } static func countWords(_ text: String) -> Int { var count = 0 text.enumerateSubstrings(in: text.startIndex..