2023-10-25 01:32:58 +04:00

3260 lines
170 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import CoreText
import AppBundle
private let defaultFont = UIFont.systemFont(ofSize: 15.0)
private let quoteIcon: UIImage = {
return UIImage(bundleImageName: "Chat/Message/ReplyQuoteIcon")!.precomposed()
}()
private final class TextNodeStrikethrough {
let range: NSRange
let frame: CGRect
init(range: NSRange, frame: CGRect) {
self.range = range
self.frame = frame
}
}
private final class TextNodeSpoiler {
let range: NSRange
let frame: CGRect
init(range: NSRange, frame: CGRect) {
self.range = range
self.frame = frame
}
}
private final class TextNodeEmbeddedItem {
let range: NSRange
let frame: CGRect
let item: AnyHashable
init(range: NSRange, frame: CGRect, item: AnyHashable) {
self.range = range
self.frame = frame
self.item = item
}
}
private final class TextNodeAttachment {
let range: NSRange
let frame: CGRect
let attachment: UIImage
init(range: NSRange, frame: CGRect, attachment: UIImage) {
self.range = range
self.frame = frame
self.attachment = attachment
}
}
public struct TextRangeRectEdge: Equatable {
public var x: CGFloat
public var y: CGFloat
public var height: CGFloat
public init(x: CGFloat, y: CGFloat, height: CGFloat) {
self.x = x
self.y = y
self.height = height
}
}
public final class TextNodeBlockQuoteData: NSObject {
public let title: NSAttributedString?
public let color: UIColor
public let secondaryColor: UIColor?
public init(title: NSAttributedString?, color: UIColor, secondaryColor: UIColor?) {
self.title = title
self.color = color
self.secondaryColor = secondaryColor
super.init()
}
override public func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TextNodeBlockQuoteData else {
return false
}
if let lhsTitle = self.title, let rhsTitle = other.title {
if !lhsTitle.isEqual(to: rhsTitle) {
return false
}
} else if (self.title == nil) != (other.title == nil) {
return false
}
if !self.color.isEqual(other.color) {
return false
}
if let lhsSecondaryColor = self.secondaryColor, let rhsSecondaryColor = other.secondaryColor {
if !lhsSecondaryColor.isEqual(rhsSecondaryColor) {
return false
}
} else if (self.secondaryColor == nil) != (other.secondaryColor == nil) {
return false
}
return true
}
}
private final class TextNodeLine {
let line: CTLine
var frame: CGRect
let ascent: CGFloat
let descent: CGFloat
let range: NSRange?
let isRTL: Bool
var strikethroughs: [TextNodeStrikethrough]
var spoilers: [TextNodeSpoiler]
var spoilerWords: [TextNodeSpoiler]
var embeddedItems: [TextNodeEmbeddedItem]
var attachments: [TextNodeAttachment]
let additionalTrailingLine: (CTLine, Double)?
init(line: CTLine, frame: CGRect, ascent: CGFloat, descent: CGFloat, range: NSRange?, isRTL: Bool, strikethroughs: [TextNodeStrikethrough], spoilers: [TextNodeSpoiler], spoilerWords: [TextNodeSpoiler], embeddedItems: [TextNodeEmbeddedItem], attachments: [TextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) {
self.line = line
self.frame = frame
self.ascent = ascent
self.descent = descent
self.range = range
self.isRTL = isRTL
self.strikethroughs = strikethroughs
self.spoilers = spoilers
self.spoilerWords = spoilerWords
self.embeddedItems = embeddedItems
self.attachments = attachments
self.additionalTrailingLine = additionalTrailingLine
}
}
private final class TextNodeBlockQuote {
let frame: CGRect
let tintColor: UIColor
let secondaryTintColor: UIColor?
init(frame: CGRect, tintColor: UIColor, secondaryTintColor: UIColor?) {
self.frame = frame
self.tintColor = tintColor
self.secondaryTintColor = secondaryTintColor
}
}
public enum TextNodeCutoutPosition {
case TopLeft
case TopRight
case BottomRight
}
public struct TextNodeCutout: Equatable {
public var topLeft: CGSize?
public var topRight: CGSize?
public var bottomRight: CGSize?
public init(topLeft: CGSize? = nil, topRight: CGSize? = nil, bottomRight: CGSize? = nil) {
self.topLeft = topLeft
self.topRight = topRight
self.bottomRight = bottomRight
}
}
private func displayLineFrame(frame: CGRect, isRTL: Bool, boundingRect: CGRect, cutout: TextNodeCutout?) -> CGRect {
if frame.width.isEqual(to: boundingRect.width) {
return frame
}
var lineFrame = frame
let intersectionFrame = lineFrame.offsetBy(dx: 0.0, dy: -lineFrame.height)
if isRTL {
lineFrame.origin.x = max(0.0, floor(boundingRect.width - lineFrame.size.width))
if let topRight = cutout?.topRight {
let topRightRect = CGRect(origin: CGPoint(x: boundingRect.width - topRight.width, y: 0.0), size: topRight)
if intersectionFrame.intersects(topRightRect) {
lineFrame.origin.x -= topRight.width
return lineFrame
}
}
if let bottomRight = cutout?.bottomRight {
let bottomRightRect = CGRect(origin: CGPoint(x: boundingRect.width - bottomRight.width, y: boundingRect.height - bottomRight.height), size: bottomRight)
if intersectionFrame.intersects(bottomRightRect) {
lineFrame.origin.x -= bottomRight.width
return lineFrame
}
}
}
return lineFrame
}
public enum TextVerticalAlignment {
case top
case middle
case bottom
}
public final class TextNodeLayoutArguments {
public let attributedString: NSAttributedString?
public let backgroundColor: UIColor?
public let minimumNumberOfLines: Int
public let maximumNumberOfLines: Int
public let truncationType: CTLineTruncationType
public let constrainedSize: CGSize
public let alignment: NSTextAlignment
public let verticalAlignment: TextVerticalAlignment
public let lineSpacing: CGFloat
public let cutout: TextNodeCutout?
public let insets: UIEdgeInsets
public let lineColor: UIColor?
public let textShadowColor: UIColor?
public let textShadowBlur: CGFloat?
public let textStroke: (UIColor, CGFloat)?
public let displaySpoilers: Bool
public let displayEmbeddedItemsUnderSpoilers: Bool
public let customTruncationToken: NSAttributedString?
public init(
attributedString: NSAttributedString?,
backgroundColor: UIColor? = nil,
minimumNumberOfLines: Int = 0,
maximumNumberOfLines: Int,
truncationType: CTLineTruncationType,
constrainedSize: CGSize,
alignment: NSTextAlignment = .natural,
verticalAlignment: TextVerticalAlignment = .top,
lineSpacing: CGFloat = 0.12,
cutout: TextNodeCutout? = nil,
insets: UIEdgeInsets = UIEdgeInsets(),
lineColor: UIColor? = nil,
textShadowColor: UIColor? = nil,
textShadowBlur: CGFloat? = nil,
textStroke: (UIColor, CGFloat)? = nil,
displaySpoilers: Bool = false,
displayEmbeddedItemsUnderSpoilers: Bool = false,
customTruncationToken: NSAttributedString? = nil
) {
self.attributedString = attributedString
self.backgroundColor = backgroundColor
self.minimumNumberOfLines = minimumNumberOfLines
self.maximumNumberOfLines = maximumNumberOfLines
self.truncationType = truncationType
self.constrainedSize = constrainedSize
self.alignment = alignment
self.verticalAlignment = verticalAlignment
self.lineSpacing = lineSpacing
self.cutout = cutout
self.insets = insets
self.lineColor = lineColor
self.textShadowColor = textShadowColor
self.textShadowBlur = textShadowBlur
self.textStroke = textStroke
self.displaySpoilers = displaySpoilers
self.displayEmbeddedItemsUnderSpoilers = displayEmbeddedItemsUnderSpoilers
self.customTruncationToken = customTruncationToken
}
public func withAttributedString(_ attributedString: NSAttributedString?) -> TextNodeLayoutArguments {
return TextNodeLayoutArguments(
attributedString: attributedString,
backgroundColor: self.backgroundColor,
minimumNumberOfLines: self.minimumNumberOfLines,
maximumNumberOfLines: self.maximumNumberOfLines,
truncationType: self.truncationType,
constrainedSize: self.constrainedSize,
alignment: self.alignment,
verticalAlignment: self.verticalAlignment,
lineSpacing: self.lineSpacing,
cutout: self.cutout,
insets: self.insets,
lineColor: self.lineColor,
textShadowColor: self.textShadowColor,
textShadowBlur: self.textShadowBlur,
textStroke: self.textStroke,
displaySpoilers: self.displaySpoilers,
displayEmbeddedItemsUnderSpoilers: self.displayEmbeddedItemsUnderSpoilers,
customTruncationToken: self.customTruncationToken
)
}
}
public final class TextNodeLayout: NSObject {
public final class EmbeddedItem: Equatable {
public let range: NSRange
public let rect: CGRect
public let value: AnyHashable
public let textColor: UIColor
public init(range: NSRange, rect: CGRect, value: AnyHashable, textColor: UIColor) {
self.range = range
self.rect = rect
self.value = value
self.textColor = textColor
}
public static func ==(lhs: EmbeddedItem, rhs: EmbeddedItem) -> Bool {
if lhs.range != rhs.range {
return false
}
if lhs.rect != rhs.rect {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.textColor != rhs.textColor {
return false
}
return true
}
}
public let attributedString: NSAttributedString?
fileprivate let maximumNumberOfLines: Int
fileprivate let truncationType: CTLineTruncationType
fileprivate let backgroundColor: UIColor?
fileprivate let constrainedSize: CGSize
fileprivate let explicitAlignment: NSTextAlignment
fileprivate let resolvedAlignment: NSTextAlignment
fileprivate let verticalAlignment: TextVerticalAlignment
fileprivate let lineSpacing: CGFloat
fileprivate let cutout: TextNodeCutout?
public let insets: UIEdgeInsets
public let size: CGSize
public let rawTextSize: CGSize
public let truncated: Bool
fileprivate let firstLineOffset: CGFloat
fileprivate let lines: [TextNodeLine]
fileprivate let blockQuotes: [TextNodeBlockQuote]
fileprivate let lineColor: UIColor?
fileprivate let textShadowColor: UIColor?
fileprivate let textShadowBlur: CGFloat?
fileprivate let textStroke: (UIColor, CGFloat)?
fileprivate let displaySpoilers: Bool
public let hasRTL: Bool
public let spoilers: [(NSRange, CGRect)]
public let spoilerWords: [(NSRange, CGRect)]
public let embeddedItems: [TextNodeLayout.EmbeddedItem]
fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, explicitAlignment: NSTextAlignment, resolvedAlignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, rawTextSize: CGSize, truncated: Bool, firstLineOffset: CGFloat, lines: [TextNodeLine], blockQuotes: [TextNodeBlockQuote], backgroundColor: UIColor?, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) {
self.attributedString = attributedString
self.maximumNumberOfLines = maximumNumberOfLines
self.truncationType = truncationType
self.constrainedSize = constrainedSize
self.explicitAlignment = explicitAlignment
self.resolvedAlignment = resolvedAlignment
self.verticalAlignment = verticalAlignment
self.lineSpacing = lineSpacing
self.cutout = cutout
self.insets = insets
self.size = size
self.rawTextSize = rawTextSize
self.truncated = truncated
self.firstLineOffset = firstLineOffset
self.lines = lines
self.blockQuotes = blockQuotes
self.backgroundColor = backgroundColor
self.lineColor = lineColor
self.textShadowColor = textShadowColor
self.textShadowBlur = textShadowBlur
self.textStroke = textStroke
self.displaySpoilers = displaySpoilers
var hasRTL = false
var spoilers: [(NSRange, CGRect)] = []
var spoilerWords: [(NSRange, CGRect)] = []
var embeddedItems: [TextNodeLayout.EmbeddedItem] = []
for line in lines {
if line.isRTL {
hasRTL = true
}
let lineFrame: CGRect
switch self.resolvedAlignment {
case .center:
lineFrame = CGRect(origin: CGPoint(x: floor((size.width - line.frame.size.width) / 2.0), y: line.frame.minY), size: line.frame.size)
default:
lineFrame = displayLineFrame(frame: line.frame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: size), cutout: cutout)
}
spoilers.append(contentsOf: line.spoilers.map { ( $0.range, $0.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) })
spoilerWords.append(contentsOf: line.spoilerWords.map { ( $0.range, $0.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) })
for embeddedItem in line.embeddedItems {
var textColor: UIColor?
if let attributedString = attributedString, embeddedItem.range.location < attributedString.length {
if let color = attributedString.attribute(.foregroundColor, at: embeddedItem.range.location, effectiveRange: nil) as? UIColor {
textColor = color
}
if textColor == nil {
if let color = attributedString.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor {
textColor = color
}
}
}
embeddedItems.append(TextNodeLayout.EmbeddedItem(range: embeddedItem.range, rect: embeddedItem.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY), value: embeddedItem.item, textColor: textColor ?? .black))
}
}
self.hasRTL = hasRTL
self.spoilers = spoilers
self.spoilerWords = spoilerWords
self.embeddedItems = embeddedItems
}
public func areLinesEqual(to other: TextNodeLayout) -> Bool {
if self.lines.count != other.lines.count {
return false
}
for i in 0 ..< self.lines.count {
if !self.lines[i].frame.equalTo(other.lines[i].frame) {
return false
}
if self.lines[i].isRTL != other.lines[i].isRTL {
return false
}
if self.lines[i].range != other.lines[i].range {
return false
}
let lhsRuns = CTLineGetGlyphRuns(self.lines[i].line) as NSArray
let rhsRuns = CTLineGetGlyphRuns(other.lines[i].line) as NSArray
if lhsRuns.count != rhsRuns.count {
return false
}
for j in 0 ..< lhsRuns.count {
let lhsRun = lhsRuns[j] as! CTRun
let rhsRun = rhsRuns[j] as! CTRun
let lhsGlyphCount = CTRunGetGlyphCount(lhsRun)
let rhsGlyphCount = CTRunGetGlyphCount(rhsRun)
if lhsGlyphCount != rhsGlyphCount {
return false
}
for k in 0 ..< lhsGlyphCount {
var lhsGlyph = CGGlyph()
var rhsGlyph = CGGlyph()
CTRunGetGlyphs(lhsRun, CFRangeMake(k, 1), &lhsGlyph)
CTRunGetGlyphs(rhsRun, CFRangeMake(k, 1), &rhsGlyph)
if lhsGlyph != rhsGlyph {
return false
}
}
}
}
return true
}
public var numberOfLines: Int {
return self.lines.count
}
public var trailingLineWidth: CGFloat {
if let lastLine = self.lines.last {
var width = lastLine.frame.maxX
for blockQuote in self.blockQuotes {
if lastLine.frame.intersects(blockQuote.frame) {
width = max(width, ceil(blockQuote.frame.maxX) + 2.0)
}
}
return width
} else {
return 0.0
}
}
public var trailingLineIsRTL: Bool {
if let lastLine = self.lines.last {
return lastLine.isRTL
} else {
return false
}
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
if let attributedString = self.attributedString {
let transformedPoint = CGPoint(x: point.x - self.insets.left, y: point.y - self.insets.top)
if orNearest {
var lineIndex = -1
var closestLine: (Int, CGRect, CGFloat)?
for line in self.lines {
lineIndex += 1
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
switch self.resolvedAlignment {
case .center:
lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0)
case .natural:
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
default:
break
}
let currentDistance = (lineFrame.center.y - point.y) * (lineFrame.center.y - point.y)
if let current = closestLine {
if current.2 > currentDistance {
closestLine = (lineIndex, lineFrame, currentDistance)
}
} else {
closestLine = (lineIndex, lineFrame, currentDistance)
}
}
if let (index, lineFrame, _) = closestLine {
let line = self.lines[index]
let lineRange = CTLineGetStringRange(line.line)
var index: Int
if transformedPoint.x <= lineFrame.minX {
index = lineRange.location
} else if transformedPoint.x >= lineFrame.maxX {
index = lineRange.location + lineRange.length
} else {
index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: floor(lineFrame.height / 2.0)))
if index != 0 {
var glyphStart: CGFloat = 0.0
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
if transformedPoint.x < glyphStart {
var closestLowerIndex: Int?
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
for i in 0 ..< glyphCount {
var glyphIndex: CFIndex = 0
CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex)
if glyphIndex < index {
if let closestLowerIndexValue = closestLowerIndex {
if closestLowerIndexValue < glyphIndex {
closestLowerIndex = glyphIndex
}
} else {
closestLowerIndex = glyphIndex
}
}
}
}
}
if let closestLowerIndex = closestLowerIndex {
index = closestLowerIndex
}
}
}
}
return (index, [:])
}
}
var lineIndex = -1
for line in self.lines {
lineIndex += 1
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
switch self.resolvedAlignment {
case .center:
lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0)
case .natural:
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
default:
break
}
if lineFrame.contains(transformedPoint) {
var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY))
if index == attributedString.length {
var closestLowerIndex: Int?
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
for i in 0 ..< glyphCount {
var glyphIndex: CFIndex = 0
CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex)
if glyphIndex < index {
if let closestLowerIndexValue = closestLowerIndex {
if closestLowerIndexValue < glyphIndex {
closestLowerIndex = glyphIndex
}
} else {
closestLowerIndex = glyphIndex
}
}
}
}
}
if let closestLowerIndex = closestLowerIndex {
index = closestLowerIndex
}
} else if index != 0 {
var glyphStart: CGFloat = 0.0
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
if transformedPoint.x < glyphStart {
var closestLowerIndex: Int?
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
for i in 0 ..< glyphCount {
var glyphIndex: CFIndex = 0
CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex)
if glyphIndex < index {
if let closestLowerIndexValue = closestLowerIndex {
if closestLowerIndexValue < glyphIndex {
closestLowerIndex = glyphIndex
}
} else {
closestLowerIndex = glyphIndex
}
}
}
}
}
if let closestLowerIndex = closestLowerIndex {
index = closestLowerIndex
}
}
}
if index >= 0 && index < attributedString.length {
if let range = line.range, index < range.location + range.length {
return (index, attributedString.attributes(at: index, effectiveRange: nil))
}
}
break
}
}
lineIndex = -1
for line in self.lines {
lineIndex += 1
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
switch self.resolvedAlignment {
case .center:
lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0)
case .natural:
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
default:
break
}
if lineFrame.offsetBy(dx: 0.0, dy: -lineFrame.size.height).insetBy(dx: -3.0, dy: -3.0).contains(transformedPoint) {
var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY))
if index == attributedString.length {
var closestLowerIndex: Int?
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
for i in 0 ..< glyphCount {
var glyphIndex: CFIndex = 0
CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex)
if glyphIndex < index {
if let closestLowerIndexValue = closestLowerIndex {
if closestLowerIndexValue < glyphIndex {
closestLowerIndex = glyphIndex
}
} else {
closestLowerIndex = glyphIndex
}
}
}
}
}
if let closestLowerIndex = closestLowerIndex {
index = closestLowerIndex
}
} else if index != 0 {
var glyphStart: CGFloat = 0.0
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
if transformedPoint.x < glyphStart {
var closestLowerIndex: Int?
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
for i in 0 ..< glyphCount {
var glyphIndex: CFIndex = 0
CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex)
if glyphIndex < index {
if let closestLowerIndexValue = closestLowerIndex {
if closestLowerIndexValue < glyphIndex {
closestLowerIndex = glyphIndex
}
} else {
closestLowerIndex = glyphIndex
}
}
}
}
}
if let closestLowerIndex = closestLowerIndex {
index = closestLowerIndex
}
}
}
if index >= 0 && index < attributedString.length {
if let range = line.range, index < range.location + range.length {
return (index, attributedString.attributes(at: index, effectiveRange: nil))
}
}
break
}
}
}
return nil
}
public func linesRects() -> [CGRect] {
var rects: [CGRect] = []
for line in self.lines {
rects.append(line.frame)
}
return rects
}
public func textRangesRects(text: String) -> [[CGRect]] {
guard let attributedString = self.attributedString else {
return []
}
let (ranges, searchText) = findSubstringRanges(in: attributedString.string, query: text)
var result: [[CGRect]] = []
for stringRange in ranges {
var rects: [CGRect] = []
let range = NSRange(stringRange, in: searchText)
for line in self.lines {
guard let rangeValue = line.range else {
continue
}
let lineRange = NSIntersectionRange(range, rangeValue)
if lineRange.length != 0 {
var leftOffset: CGFloat = 0.0
if lineRange.location != rangeValue.location {
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
}
var rightOffset: CGFloat = line.frame.width
if lineRange.location + lineRange.length != rangeValue.length {
var secondaryOffset: CGFloat = 0.0
let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset)
rightOffset = ceil(rawOffset)
if !rawOffset.isEqual(to: secondaryOffset) {
rightOffset = ceil(secondaryOffset)
}
}
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
let width = abs(rightOffset - leftOffset)
rects.append(CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: width, height: lineFrame.size.height)))
}
}
if !rects.isEmpty {
result.append(rects)
}
}
return result
}
public func attributeSubstring(name: String, index: Int) -> (String, String)? {
if let attributedString = self.attributedString {
var range = NSRange()
let _ = attributedString.attribute(NSAttributedString.Key(rawValue: name), at: index, effectiveRange: &range)
if range.length != 0 {
return ((attributedString.string as NSString).substring(with: range), attributedString.string)
}
}
return nil
}
public func attributeSubstringWithRange(name: String, index: Int) -> (String, String, NSRange)? {
if let attributedString = self.attributedString {
var range = NSRange()
let _ = attributedString.attribute(NSAttributedString.Key(rawValue: name), at: index, effectiveRange: &range)
if range.length != 0 {
return ((attributedString.string as NSString).substring(with: range), attributedString.string, range)
}
}
return nil
}
public func allAttributeRects(name: String) -> [(Any, CGRect)] {
guard let attributedString = self.attributedString else {
return []
}
var result: [(Any, CGRect)] = []
attributedString.enumerateAttribute(NSAttributedString.Key(rawValue: name), in: NSRange(location: 0, length: attributedString.length), options: []) { (value, range, _) in
if let value = value, range.length != 0 {
var coveringRect = CGRect()
for line in self.lines {
guard let rangeValue = line.range else {
continue
}
let lineRange = NSIntersectionRange(range, rangeValue)
if lineRange.length != 0 {
var leftOffset: CGFloat = 0.0
if lineRange.location != rangeValue.location {
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
}
var rightOffset: CGFloat = line.frame.width
if lineRange.location + lineRange.length != rangeValue.length {
var secondaryOffset: CGFloat = 0.0
let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset)
rightOffset = ceil(rawOffset)
if !rawOffset.isEqual(to: secondaryOffset) {
rightOffset = ceil(secondaryOffset)
}
}
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
switch self.resolvedAlignment {
case .center:
lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0)
case .natural:
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
default:
break
}
let rect = CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: abs(rightOffset - leftOffset), height: lineFrame.size.height))
if coveringRect.isEmpty {
coveringRect = rect
} else {
coveringRect = coveringRect.union(rect)
}
}
}
if !coveringRect.isEmpty {
result.append((value, coveringRect))
}
}
}
return result
}
public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? {
if let attributedString = self.attributedString {
var range = NSRange()
let _ = attributedString.attribute(NSAttributedString.Key(rawValue: name), at: index, effectiveRange: &range)
if range.length != 0 {
var rects: [(CGRect, CGRect)] = []
for line in self.lines {
guard let rangeValue = line.range else {
continue
}
let lineRange = NSIntersectionRange(range, rangeValue)
if lineRange.length != 0 {
var leftOffset: CGFloat = 0.0
if lineRange.location != rangeValue.location || line.isRTL {
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
}
var rightOffset: CGFloat = line.frame.width
if lineRange.location + lineRange.length != rangeValue.length || line.isRTL {
var secondaryOffset: CGFloat = 0.0
let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset)
rightOffset = ceil(rawOffset)
if !rawOffset.isEqual(to: secondaryOffset) {
rightOffset = ceil(secondaryOffset)
}
}
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
let width = abs(rightOffset - leftOffset)
if width > 1.0 {
rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: width, height: lineFrame.size.height))))
}
}
}
if !rects.isEmpty {
return rects
}
}
}
return nil
}
public func rangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? {
guard let _ = self.attributedString, range.length != 0 else {
return nil
}
var rects: [(CGRect, CGRect)] = []
var startEdge: TextRangeRectEdge?
var endEdge: TextRangeRectEdge?
for line in self.lines {
guard let rangeValue = line.range else {
continue
}
let lineRange = NSIntersectionRange(range, rangeValue)
if lineRange.length != 0 {
var leftOffset: CGFloat = 0.0
if lineRange.location != rangeValue.location || line.isRTL {
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
}
var rightOffset: CGFloat = line.frame.width
if lineRange.location + lineRange.length != rangeValue.upperBound || line.isRTL {
var secondaryOffset: CGFloat = 0.0
let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset)
rightOffset = ceil(rawOffset)
if !rawOffset.isEqual(to: secondaryOffset) {
rightOffset = ceil(secondaryOffset)
}
}
var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size)
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout)
let width = max(0.0, abs(rightOffset - leftOffset))
if rangeValue.contains(range.lowerBound) {
let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil))
startEdge = TextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
}
if rangeValue.contains(range.upperBound - 1) {
let offsetX: CGFloat
if rangeValue.upperBound == range.upperBound {
offsetX = lineFrame.maxX
} else {
var secondaryOffset: CGFloat = 0.0
let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset))
secondaryOffset = floor(secondaryOffset)
let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset))
if primaryOffset != secondaryOffset {
offsetX = secondaryOffset
} else {
offsetX = nextOffet
}
}
endEdge = TextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
}
rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: width, height: lineFrame.size.height))))
}
}
if !rects.isEmpty, let startEdge = startEdge, let endEdge = endEdge {
return (rects.map { $1 }, startEdge, endEdge)
}
return nil
}
}
private final class TextAccessibilityOverlayElement: UIAccessibilityElement {
private let url: String
private let openUrl: (String) -> Void
init(accessibilityContainer: Any, url: String, openUrl: @escaping (String) -> Void) {
self.url = url
self.openUrl = openUrl
super.init(accessibilityContainer: accessibilityContainer)
}
override func accessibilityActivate() -> Bool {
self.openUrl(self.url)
return true
}
}
private final class TextAccessibilityOverlayNodeView: UIView {
fileprivate var cachedLayout: TextNodeLayout? {
didSet {
self.currentAccessibilityNodes?.forEach({ $0.removeFromSupernode() })
self.currentAccessibilityNodes = nil
}
}
fileprivate let openUrl: (String) -> Void
private var currentAccessibilityNodes: [AccessibilityAreaNode]?
override var accessibilityElements: [Any]? {
get {
if let _ = self.currentAccessibilityNodes {
return nil
}
guard let cachedLayout = self.cachedLayout else {
return nil
}
let urlAttributesAndRects = cachedLayout.allAttributeRects(name: "UrlAttributeT")
var urlElements: [AccessibilityAreaNode] = []
for (value, rect) in urlAttributesAndRects {
let element = AccessibilityAreaNode()
element.accessibilityLabel = value as? String ?? ""
element.frame = rect
element.accessibilityTraits = .link
element.activate = { [weak self] in
self?.openUrl(value as? String ?? "")
return true
}
self.addSubnode(element)
urlElements.append(element)
}
self.currentAccessibilityNodes = urlElements
return nil
} set(value) {
}
}
init(openUrl: @escaping (String) -> Void) {
self.openUrl = openUrl
super.init(frame: CGRect())
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
public final class TextAccessibilityOverlayNode: ASDisplayNode {
public var cachedLayout: TextNodeLayout? {
didSet {
if self.isNodeLoaded {
(self.view as? TextAccessibilityOverlayNodeView)?.cachedLayout = self.cachedLayout
}
}
}
public var openUrl: ((String) -> Void)?
override public init() {
super.init()
self.isOpaque = false
self.backgroundColor = nil
let openUrl: (String) -> Void = { [weak self] url in
self?.openUrl?(url)
}
self.isAccessibilityElement = false
self.setViewBlock({
return TextAccessibilityOverlayNodeView(openUrl: openUrl)
})
}
override public func didLoad() {
super.didLoad()
(self.view as? TextAccessibilityOverlayNodeView)?.cachedLayout = self.cachedLayout
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return nil
}
}
private func addSpoiler(line: TextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
line.spoilers.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent)))
}
private func addSpoilerWord(line: TextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
line.spoilerWords.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent)))
}
private func addEmbeddedItem(item: AnyHashable, line: TextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
line.embeddedItems.append(TextNodeEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), item: item))
}
private func addAttachment(attachment: UIImage, line: TextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
line.attachments.append(TextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment))
}
open class TextNode: ASDisplayNode {
public internal(set) var cachedLayout: TextNodeLayout?
override public init() {
super.init()
self.backgroundColor = UIColor.clear
self.isOpaque = false
self.clipsToBounds = false
}
override open func didLoad() {
super.didLoad()
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.attributesAtPoint(point, orNearest: orNearest)
} else {
return nil
}
}
public func textRangesRects(text: String) -> [[CGRect]] {
return self.cachedLayout?.textRangesRects(text: text) ?? []
}
public func attributeSubstring(name: String, index: Int) -> (String, String)? {
return self.cachedLayout?.attributeSubstring(name: name, index: index)
}
public func attributeSubstringWithRange(name: String, index: Int) -> (String, String, NSRange)? {
return self.cachedLayout?.attributeSubstringWithRange(name: name, index: index)
}
public func attributeRects(name: String, at index: Int) -> [CGRect]? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.lineAndAttributeRects(name: name, at: index)?.map { $0.1 }
} else {
return nil
}
}
public func rangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.rangeRects(in: range)
} else {
return nil
}
}
public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.lineAndAttributeRects(name: name, at: index)
} else {
return nil
}
}
private static func calculateLayoutV2(
attributedString: NSAttributedString,
minimumNumberOfLines: Int,
maximumNumberOfLines: Int,
truncationType: CTLineTruncationType,
backgroundColor: UIColor?,
constrainedSize: CGSize,
alignment: NSTextAlignment,
verticalAlignment: TextVerticalAlignment,
lineSpacingFactor: CGFloat,
cutout: TextNodeCutout?,
insets: UIEdgeInsets,
lineColor: UIColor?,
textShadowColor: UIColor?,
textShadowBlur: CGFloat?,
textStroke: (UIColor, CGFloat)?,
displaySpoilers: Bool,
displayEmbeddedItemsUnderSpoilers: Bool,
customTruncationToken: NSAttributedString?
) -> TextNodeLayout {
let blockQuoteLeftInset: CGFloat = 9.0
let blockQuoteRightInset: CGFloat = 0.0
let blockQuoteIconInset: CGFloat = 7.0
struct StringSegment {
let title: NSAttributedString?
let substring: NSAttributedString
let firstCharacterOffset: Int
let isBlockQuote: Bool
let tintColor: UIColor?
let secondaryTintColor: UIColor?
}
var stringSegments: [StringSegment] = []
let rawWholeString = attributedString.string as NSString
let wholeStringLength = rawWholeString.length
var segmentCharacterOffset = 0
while true {
var found = false
attributedString.enumerateAttribute(NSAttributedString.Key("Attribute__Blockquote"), in: NSRange(location: segmentCharacterOffset, length: wholeStringLength - segmentCharacterOffset), using: { value, effectiveRange, stop in
found = true
stop.pointee = ObjCBool(true)
if segmentCharacterOffset != effectiveRange.location {
stringSegments.append(StringSegment(
title: nil,
substring: attributedString.attributedSubstring(from: NSRange(
location: segmentCharacterOffset,
length: effectiveRange.location - segmentCharacterOffset
)),
firstCharacterOffset: segmentCharacterOffset,
isBlockQuote: false,
tintColor: nil,
secondaryTintColor: nil
))
}
if let value = value as? TextNodeBlockQuoteData {
if effectiveRange.length != 0 {
stringSegments.append(StringSegment(
title: value.title,
substring: attributedString.attributedSubstring(from: effectiveRange),
firstCharacterOffset: effectiveRange.location,
isBlockQuote: true,
tintColor: value.color,
secondaryTintColor: value.secondaryColor
))
}
segmentCharacterOffset = effectiveRange.location + effectiveRange.length
if segmentCharacterOffset < wholeStringLength && rawWholeString.character(at: segmentCharacterOffset) == 0x0a {
segmentCharacterOffset += 1
}
} else {
stringSegments.append(StringSegment(
title: nil,
substring: attributedString.attributedSubstring(from: effectiveRange),
firstCharacterOffset: effectiveRange.location,
isBlockQuote: false,
tintColor: nil,
secondaryTintColor: nil
))
segmentCharacterOffset = effectiveRange.location + effectiveRange.length
}
})
if !found {
if segmentCharacterOffset != wholeStringLength {
stringSegments.append(StringSegment(
title: nil,
substring: attributedString.attributedSubstring(from: NSRange(
location: segmentCharacterOffset,
length: wholeStringLength - segmentCharacterOffset
)),
firstCharacterOffset: segmentCharacterOffset,
isBlockQuote: false,
tintColor: nil,
secondaryTintColor: nil
))
}
break
}
}
struct CalculatedSegment {
var titleLine: TextNodeLine?
var lines: [TextNodeLine] = []
var tintColor: UIColor?
var secondaryTintColor: UIColor?
var isBlockQuote: Bool = false
var additionalWidth: CGFloat = 0.0
}
var calculatedSegments: [CalculatedSegment] = []
for segment in stringSegments {
var calculatedSegment = CalculatedSegment()
calculatedSegment.isBlockQuote = segment.isBlockQuote
calculatedSegment.tintColor = segment.tintColor
calculatedSegment.secondaryTintColor = segment.secondaryTintColor
let rawSubstring = segment.substring.string as NSString
let substringLength = rawSubstring.length
let segmentTypesetterString = attributedString.attributedSubstring(from: NSRange(location: 0, length: segment.firstCharacterOffset + substringLength))
let typesetter = CTTypesetterCreateWithAttributedString(segmentTypesetterString as CFAttributedString)
var currentLineStartIndex = segment.firstCharacterOffset
let segmentEndIndex = segment.firstCharacterOffset + substringLength
var constrainedSegmentWidth = constrainedSize.width
var additionalOffsetX: CGFloat = 0.0
if segment.isBlockQuote {
additionalOffsetX += blockQuoteLeftInset
constrainedSegmentWidth -= additionalOffsetX + blockQuoteLeftInset + blockQuoteRightInset
calculatedSegment.additionalWidth += blockQuoteLeftInset + blockQuoteRightInset
}
var additionalSegmentRightInset = blockQuoteIconInset
if let title = segment.title {
let rawTitleLine = CTLineCreateWithAttributedString(title)
if let titleLine = CTLineCreateTruncatedLine(rawTitleLine, constrainedSegmentWidth - additionalSegmentRightInset, .end, nil) {
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = CTLineGetTypographicBounds(titleLine, &lineAscent, &lineDescent, nil)
calculatedSegment.titleLine = TextNodeLine(
line: titleLine,
frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)),
ascent: lineAscent,
descent: lineDescent,
range: nil,
isRTL: false,
strikethroughs: [],
spoilers: [],
spoilerWords: [],
embeddedItems: [],
attachments: [],
additionalTrailingLine: nil
)
additionalSegmentRightInset = 0.0
}
}
while true {
let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, currentLineStartIndex, constrainedSegmentWidth - additionalSegmentRightInset)
if lineCharacterCount != 0 {
let line = CTTypesetterCreateLine(typesetter, CFRange(location: currentLineStartIndex, length: lineCharacterCount))
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, nil)
var isRTL = false
let glyphRuns = CTLineGetGlyphRuns(line) as NSArray
if glyphRuns.count != 0 {
let run = glyphRuns[0] as! CTRun
if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
}
calculatedSegment.lines.append(TextNodeLine(
line: line,
frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)),
ascent: lineAscent,
descent: lineDescent,
range: NSRange(location: currentLineStartIndex, length: lineCharacterCount),
isRTL: isRTL && !segment.isBlockQuote,
strikethroughs: [],
spoilers: [],
spoilerWords: [],
embeddedItems: [],
attachments: [],
additionalTrailingLine: nil
))
}
additionalSegmentRightInset = 0.0
currentLineStartIndex += lineCharacterCount
if currentLineStartIndex >= segmentEndIndex {
break
}
}
calculatedSegments.append(calculatedSegment)
}
var size = CGSize()
let isTruncated = false
for segment in calculatedSegments {
if let titleLine = segment.titleLine {
size.width = max(size.width, titleLine.frame.origin.x + titleLine.frame.width + segment.additionalWidth)
}
for line in segment.lines {
size.width = max(size.width, line.frame.origin.x + line.frame.width + segment.additionalWidth)
}
}
var lines: [TextNodeLine] = []
var blockQuotes: [TextNodeBlockQuote] = []
for i in 0 ..< calculatedSegments.count {
let segment = calculatedSegments[i]
if i != 0 {
if segment.isBlockQuote {
size.height += 6.0
}
} else {
if segment.isBlockQuote {
size.height += 7.0
}
}
let blockMinY = size.height - insets.bottom
var blockWidth: CGFloat = 0.0
if let titleLine = segment.titleLine {
titleLine.frame = CGRect(origin: CGPoint(x: titleLine.frame.origin.x, y: -insets.bottom + size.height + titleLine.frame.size.height), size: titleLine.frame.size)
titleLine.frame.size.width += max(0.0, segment.additionalWidth - 2.0)
size.height += titleLine.frame.height
blockWidth = max(blockWidth, titleLine.frame.origin.x + titleLine.frame.width)
lines.append(titleLine)
}
for line in segment.lines {
line.frame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: -insets.bottom + size.height + line.frame.size.height), size: line.frame.size)
line.frame.size.width += max(0.0, segment.additionalWidth - 2.0)
//line.frame.size.width = max(blockWidth, line.frame.size.width)
size.height += line.frame.height
blockWidth = max(blockWidth, line.frame.origin.x + line.frame.width)
if let range = line.range {
attributedString.enumerateAttributes(in: range, options: []) { attributes, range, _ in
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(line.line, &ascent, &descent, nil)
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedString.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoilerWord(line: line, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoilerWord(line: line, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: 0.0)
}
addSpoiler(line: line, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
} else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(line.line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line.line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
line.strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: line.frame.height)))
}
if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) {
if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(line.line, &ascent, &descent, nil)
addEmbeddedItem(item: embeddedItem, line: line, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
}
}
if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(line.line, &ascent, &descent, nil)
addAttachment(attachment: attachment, line: line, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
}
}
}
lines.append(line)
}
let blockMaxY = size.height - insets.bottom
if i != calculatedSegments.count - 1 {
if segment.isBlockQuote {
size.height += 8.0
}
} else {
if segment.isBlockQuote {
size.height += 6.0
}
}
if segment.isBlockQuote, let tintColor = segment.tintColor {
blockQuotes.append(TextNodeBlockQuote(frame: CGRect(origin: CGPoint(x: 0.0, y: blockMinY - 2.0), size: CGSize(width: blockWidth, height: blockMaxY - (blockMinY - 2.0) + 4.0)), tintColor: tintColor, secondaryTintColor: segment.secondaryTintColor))
}
}
size.width = ceil(size.width)
size.height = ceil(size.height)
let rawTextSize = size
size.width += insets.left + insets.right
size.height += insets.top + insets.bottom
return TextNodeLayout(
attributedString: attributedString,
maximumNumberOfLines: maximumNumberOfLines,
truncationType: truncationType,
constrainedSize: constrainedSize,
explicitAlignment: alignment,
resolvedAlignment: alignment,
verticalAlignment: verticalAlignment,
lineSpacing: lineSpacingFactor,
cutout: cutout,
insets: insets,
size: size,
rawTextSize: rawTextSize,
truncated: isTruncated,
firstLineOffset: lines.first?.descent ?? 0.0,
lines: lines,
blockQuotes: blockQuotes,
backgroundColor: backgroundColor,
lineColor: lineColor,
textShadowColor: textShadowColor,
textShadowBlur: textShadowBlur,
textStroke: textStroke,
displaySpoilers: displaySpoilers
)
}
static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool, displayEmbeddedItemsUnderSpoilers: Bool, customTruncationToken: NSAttributedString?) -> TextNodeLayout {
guard let attributedString else {
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers)
}
if maximumNumberOfLines == 0 {
var found = false
attributedString.enumerateAttribute(NSAttributedString.Key("Attribute__Blockquote"), in: NSRange(location: 0, length: attributedString.length), using: { value, effectiveRange, _ in
if let _ = value as? TextNodeBlockQuoteData {
found = true
}
})
if found {
return calculateLayoutV2(attributedString: attributedString, minimumNumberOfLines: minimumNumberOfLines, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, alignment: alignment, verticalAlignment: verticalAlignment, lineSpacingFactor: lineSpacingFactor, cutout: cutout, insets: insets, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers, displayEmbeddedItemsUnderSpoilers: displayEmbeddedItemsUnderSpoilers, customTruncationToken: customTruncationToken)
}
}
let stringLength = attributedString.length
let font: CTFont
let resolvedAlignment: NSTextAlignment
if stringLength != 0 {
if let stringFont = attributedString.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) {
font = stringFont as! CTFont
} else {
font = defaultFont
}
if alignment == .center {
resolvedAlignment = .center
} else {
if let paragraphStyle = attributedString.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle {
resolvedAlignment = paragraphStyle.alignment
} else {
resolvedAlignment = alignment
}
}
} else {
font = defaultFont
resolvedAlignment = alignment
}
let fontAscent = CTFontGetAscent(font)
let fontDescent = CTFontGetDescent(font)
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
var lines: [TextNodeLine] = []
let blockQuotes: [TextNodeBlockQuote] = []
var maybeTypesetter: CTTypesetter?
maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString)
if maybeTypesetter == nil {
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers)
}
let typesetter = maybeTypesetter!
var lastLineCharacterIndex: CFIndex = 0
var layoutSize = CGSize()
var cutoutEnabled = false
var cutoutMinY: CGFloat = 0.0
var cutoutMaxY: CGFloat = 0.0
var cutoutWidth: CGFloat = 0.0
var cutoutOffset: CGFloat = 0.0
var bottomCutoutEnabled = false
var bottomCutoutSize = CGSize()
if let topLeft = cutout?.topLeft {
cutoutMinY = -fontLineSpacing
cutoutMaxY = topLeft.height + fontLineSpacing
cutoutWidth = topLeft.width
cutoutOffset = cutoutWidth
cutoutEnabled = true
} else if let topRight = cutout?.topRight {
cutoutMinY = -fontLineSpacing
cutoutMaxY = topRight.height + fontLineSpacing
cutoutWidth = topRight.width
cutoutEnabled = true
}
if let bottomRight = cutout?.bottomRight {
bottomCutoutSize = bottomRight
bottomCutoutEnabled = true
}
let firstLineOffset = floorToScreenPixels(fontDescent)
var truncated = false
var first = true
while true {
var strikethroughs: [TextNodeStrikethrough] = []
var spoilers: [TextNodeSpoiler] = []
var spoilerWords: [TextNodeSpoiler] = []
var embeddedItems: [TextNodeEmbeddedItem] = []
var attachments: [TextNodeAttachment] = []
var lineConstrainedWidth = constrainedSize.width
var lineConstrainedWidthDelta: CGFloat = 0.0
var lineOriginY = floorToScreenPixels(layoutSize.height + fontAscent)
if !first {
lineOriginY += fontLineSpacing
}
var lineCutoutOffset: CGFloat = 0.0
var lineAdditionalWidth: CGFloat = 0.0
if cutoutEnabled {
if lineOriginY - fontLineHeight < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY {
lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth)
lineConstrainedWidthDelta = -cutoutWidth
lineCutoutOffset = cutoutOffset
lineAdditionalWidth = cutoutWidth
}
}
let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth))
func addSpoiler(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
spoilers.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent)))
}
func addSpoilerWord(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
spoilerWords.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent)))
}
func addEmbeddedItem(item: AnyHashable, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
embeddedItems.append(TextNodeEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), item: item))
}
func addAttachment(attachment: UIImage, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, isAtEndOfTheLine: Bool, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var rightOffset: CGFloat = leftOffset
if isAtEndOfTheLine {
let rawRightOffset = CTLineGetTypographicBounds(line, nil, nil, nil)
rightOffset = floor(rawRightOffset)
} else {
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
}
attachments.append(TextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment))
}
var isLastLine = false
if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 {
isLastLine = true
} else if layoutSize.height + (fontLineSpacing + fontLineHeight) * 2.0 > constrainedSize.height {
isLastLine = true
}
if isLastLine {
if first {
first = false
} else {
layoutSize.height += fontLineSpacing
}
var didClipLinebreak = false
var lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex)
let nsString = (attributedString.string as NSString)
for i in lineRange.location ..< (lineRange.location + lineRange.length) {
if nsString.character(at: i) == 0x0a {
lineRange.length = max(0, i - lineRange.location)
didClipLinebreak = true
break
}
}
var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount)
if brokenLineRange.location + brokenLineRange.length > attributedString.length {
brokenLineRange.length = attributedString.length - brokenLineRange.location
}
if lineRange.length == 0 && !didClipLinebreak {
break
}
let coreTextLine: CTLine
let originalLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 0.0)
var lineConstrainedSize = constrainedSize
lineConstrainedSize.width += lineConstrainedWidthDelta
if bottomCutoutEnabled {
lineConstrainedSize.width -= bottomCutoutSize.width
}
let truncatedTokenString: NSAttributedString
if let customTruncationToken {
if lineRange.length == 0 && customTruncationToken.string.hasPrefix("\u{2026} ") {
truncatedTokenString = customTruncationToken.attributedSubstring(from: NSRange(location: 2, length: customTruncationToken.length - 2))
} else {
truncatedTokenString = customTruncationToken
}
} else {
var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:]
truncationTokenAttributes[NSAttributedString.Key.font] = font
truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber
let tokenString = "\u{2026}"
truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes)
}
let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString)
let truncationTokenWidth = CTLineGetTypographicBounds(truncationToken, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(truncationToken)
var effectiveLineRange = brokenLineRange
var additionalTrailingLine: (CTLine, Double)?
var measureFitWidth = CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine)
if customTruncationToken != nil && lineRange.location + lineRange.length < attributedString.length {
measureFitWidth += truncationTokenWidth
}
if lineRange.length == 0 || measureFitWidth < Double(lineConstrainedSize.width) {
if didClipLinebreak {
if lineRange.length == 0 {
coreTextLine = CTLineCreateWithAttributedString(NSAttributedString())
} else {
coreTextLine = originalLine
}
additionalTrailingLine = (truncationToken, truncationTokenWidth)
truncated = true
} else {
coreTextLine = originalLine
}
} else {
if customTruncationToken != nil {
let coreTextLine1 = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken) ?? truncationToken
let runs = (CTLineGetGlyphRuns(coreTextLine1) as [AnyObject]) as! [CTRun]
var hasTruncationToken = false
for run in runs {
let runRange = CTRunGetStringRange(run)
if runRange.location + runRange.length >= nsString.length {
hasTruncationToken = true
break
}
}
if hasTruncationToken {
coreTextLine = coreTextLine1
} else {
let coreTextLine2 = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width) - truncationTokenWidth), truncationType, truncationToken) ?? truncationToken
coreTextLine = coreTextLine2
}
} else {
coreTextLine = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken) ?? truncationToken
}
let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun]
for run in runs {
let runAttributes: NSDictionary = CTRunGetAttributes(run)
if let _ = runAttributes["CTForegroundColorFromContext"] {
brokenLineRange.length = CTRunGetStringRange(run).location - brokenLineRange.location
break
}
}
if customTruncationToken != nil {
assert(true)
}
effectiveLineRange = CFRange(location: effectiveLineRange.location, length: 0)
for run in runs {
let runRange = CTRunGetStringRange(run)
if runRange.location + runRange.length > brokenLineRange.location + brokenLineRange.length {
continue
}
effectiveLineRange.length = max(effectiveLineRange.length, (runRange.location + runRange.length) - effectiveLineRange.location)
}
if brokenLineRange.location + brokenLineRange.length > attributedString.length {
brokenLineRange.length = attributedString.length - brokenLineRange.location
}
if effectiveLineRange.location + effectiveLineRange.length > attributedString.length {
effectiveLineRange.length = attributedString.length - effectiveLineRange.location
}
truncated = true
}
var headIndent: CGFloat = 0.0
if brokenLineRange.location >= 0 && brokenLineRange.length > 0 && brokenLineRange.location + brokenLineRange.length <= attributedString.length {
attributedString.enumerateAttributes(in: NSMakeRange(brokenLineRange.location, brokenLineRange.length), options: []) { attributes, range, _ in
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedString.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: truncated ? 12.0 : 0.0)
}
addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
} else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight)))
} else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle {
headIndent = paragraphStyle.headIndent
}
if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) {
if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
}
}
if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: max(range.location, min(lineRange.location + lineRange.length - 1, range.location + range.length)), isAtEndOfTheLine: range.location + range.length >= lineRange.location + lineRange.length - 1)
}
}
}
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))))
let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight)
layoutSize.height += fontLineHeight + fontLineSpacing
if let (_, additionalTrailingLineWidth) = additionalTrailingLine {
lineAdditionalWidth += additionalTrailingLineWidth
}
layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth)
if headIndent > 0.0 {
//blockQuotes.append(TextNodeBlockQuote(frame: lineFrame))
}
var isRTL = false
let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray
if glyphRuns.count != 0 {
let run = glyphRuns[0] as! CTRun
if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
}
lines.append(TextNodeLine(
line: coreTextLine,
frame: lineFrame,
ascent: lineAscent,
descent: lineDescent,
range: NSMakeRange(effectiveLineRange.location, effectiveLineRange.length),
isRTL: isRTL,
strikethroughs: strikethroughs,
spoilers: spoilers,
spoilerWords: spoilerWords,
embeddedItems: embeddedItems,
attachments: attachments,
additionalTrailingLine: additionalTrailingLine
))
break
} else {
if lineCharacterCount > 0 {
if first {
first = false
} else {
layoutSize.height += fontLineSpacing
}
var lineRange = CFRangeMake(lastLineCharacterIndex, lineCharacterCount)
if lineRange.location + lineRange.length > attributedString.length {
lineRange.length = attributedString.length - lineRange.location
}
if lineRange.length < 0 {
break
}
let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 100.0)
lastLineCharacterIndex += lineCharacterCount
var headIndent: CGFloat = 0.0
attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedString.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
} else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight)))
} else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle {
headIndent = paragraphStyle.headIndent
}
if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) {
if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
}
}
if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: max(range.location, min(lineRange.location + lineRange.length - 1, range.location + range.length)), isAtEndOfTheLine: range.location + range.length >= lineRange.location + lineRange.length - 1)
}
}
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))
let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight)
layoutSize.height += fontLineHeight
layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth + headIndent)
if headIndent > 0.0 {
//blockQuotes.append(TextNodeBlockQuote(frame: lineFrame))
}
var isRTL = false
let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray
if glyphRuns.count != 0 {
let run = glyphRuns[0] as! CTRun
if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
}
lines.append(TextNodeLine(
line: coreTextLine,
frame: lineFrame,
ascent: lineAscent,
descent: lineDescent,
range: NSMakeRange(lineRange.location, lineRange.length),
isRTL: isRTL,
strikethroughs: strikethroughs,
spoilers: spoilers,
spoilerWords: spoilerWords,
embeddedItems: embeddedItems,
attachments: attachments,
additionalTrailingLine: nil
))
} else {
if !lines.isEmpty {
layoutSize.height += fontLineSpacing
}
break
}
}
}
let rawLayoutSize = layoutSize
if !lines.isEmpty && bottomCutoutEnabled {
let proposedWidth = lines[lines.count - 1].frame.width + bottomCutoutSize.width
if proposedWidth > layoutSize.width {
if proposedWidth <= constrainedSize.width + .ulpOfOne {
layoutSize.width = proposedWidth
} else {
layoutSize.height += bottomCutoutSize.height
}
}
}
if lines.count < minimumNumberOfLines {
var lineCount = lines.count
while lineCount < minimumNumberOfLines {
if lineCount != 0 {
layoutSize.height += fontLineSpacing
}
layoutSize.height += fontLineHeight
lineCount += 1
}
}
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers)
}
override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return self.cachedLayout
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
if isCancelled() {
return
}
let context = UIGraphicsGetCurrentContext()!
context.setAllowsAntialiasing(true)
context.setAllowsFontSmoothing(false)
context.setShouldSmoothFonts(false)
context.setAllowsFontSubpixelPositioning(false)
context.setShouldSubpixelPositionFonts(false)
context.setAllowsFontSubpixelQuantization(true)
context.setShouldSubpixelQuantizeFonts(true)
var blendMode: CGBlendMode = .normal
var clearRects: [CGRect] = []
if let layout = parameters as? TextNodeLayout {
if !isRasterizing || layout.backgroundColor != nil {
context.setBlendMode(.copy)
blendMode = .copy
context.setFillColor((layout.backgroundColor ?? UIColor.clear).cgColor)
context.fill(bounds)
context.setBlendMode(.normal)
blendMode = .normal
}
let alignment = layout.resolvedAlignment
var offset = CGPoint(x: layout.insets.left, y: layout.insets.top)
switch layout.verticalAlignment {
case .top:
break
case .middle:
offset.y = floor((bounds.height - layout.size.height) / 2.0) + layout.insets.top
case .bottom:
offset.y = floor(bounds.height - layout.size.height) + layout.insets.top
}
if !layout.lines.isEmpty {
offset.y += layout.lines[0].descent
}
for blockQuote in layout.blockQuotes {
let radius: CGFloat = 4.0
let lineWidth: CGFloat = 3.0
var blockFrame = blockQuote.frame.offsetBy(dx: offset.x + 2.0, dy: offset.y)
if blockFrame.origin.x + blockFrame.size.width > bounds.width - layout.insets.right - 2.0 - 30.0 {
blockFrame.size.width = bounds.width - layout.insets.right - blockFrame.origin.x - 2.0
}
blockFrame.size.width += 4.0
blockFrame.origin.x -= 2.0
context.setFillColor(blockQuote.tintColor.withMultipliedAlpha(0.1).cgColor)
context.addPath(UIBezierPath(roundedRect: blockFrame, cornerRadius: radius).cgPath)
context.fillPath()
context.setFillColor(blockQuote.tintColor.cgColor)
let quoteRect = CGRect(origin: CGPoint(x: blockFrame.maxX - 4.0 - quoteIcon.size.width, y: blockFrame.minY + 4.0), size: quoteIcon.size)
context.saveGState()
context.translateBy(x: quoteRect.midX, y: quoteRect.midY)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -quoteRect.midX, y: -quoteRect.midY)
context.clip(to: quoteRect, mask: quoteIcon.cgImage!)
context.fill(quoteRect)
context.restoreGState()
context.resetClip()
let lineFrame = CGRect(origin: CGPoint(x: blockFrame.minX, y: blockFrame.minY), size: CGSize(width: lineWidth, height: blockFrame.height))
context.move(to: CGPoint(x: lineFrame.minX, y: lineFrame.minY + radius))
context.addArc(tangent1End: CGPoint(x: lineFrame.minX, y: lineFrame.minY), tangent2End: CGPoint(x: lineFrame.minX + radius, y: lineFrame.minY), radius: radius)
context.addLine(to: CGPoint(x: lineFrame.minX + radius, y: lineFrame.maxY))
context.addArc(tangent1End: CGPoint(x: lineFrame.minX, y: lineFrame.maxY), tangent2End: CGPoint(x: lineFrame.minX, y: lineFrame.maxY - radius), radius: radius)
context.closePath()
context.clip()
if let secondaryTintColor = blockQuote.secondaryTintColor {
let isMonochrome = secondaryTintColor.alpha == 0.0
do {
context.saveGState()
if isMonochrome {
context.setFillColor(blockQuote.tintColor.withMultipliedAlpha(0.2).cgColor)
context.fill(lineFrame)
context.setFillColor(blockQuote.tintColor.cgColor)
} else {
context.setFillColor(blockQuote.tintColor.cgColor)
context.fill(lineFrame)
context.setFillColor(secondaryTintColor.cgColor)
}
let dashOffset: CGFloat = isMonochrome ? -4.0 : 5.0
context.translateBy(x: blockFrame.minX, y: blockFrame.minY + dashOffset)
var offset = 0.0
while offset < blockFrame.height {
context.move(to: CGPoint(x: 0.0, y: 3.0))
context.addLine(to: CGPoint(x: lineWidth, y: 0.0))
context.addLine(to: CGPoint(x: lineWidth, y: 9.0))
context.addLine(to: CGPoint(x: 0.0, y: 9.0 + 3.0))
context.closePath()
context.fillPath()
context.translateBy(x: 0.0, y: 18.0)
offset += 18.0
}
context.restoreGState()
}
} else {
context.setFillColor(blockQuote.tintColor.cgColor)
context.fill(lineFrame)
}
context.resetClip()
}
if let textShadowColor = layout.textShadowColor {
context.setTextDrawingMode(.fill)
context.setShadow(offset: layout.textShadowBlur != nil ? .zero : CGSize(width: 0.0, height: 1.0), blur: layout.textShadowBlur ?? 0.0, color: textShadowColor.cgColor)
}
if let (textStrokeColor, textStrokeWidth) = layout.textStroke {
context.setBlendMode(.normal)
blendMode = .normal
context.setLineCap(.round)
context.setLineJoin(.round)
context.setStrokeColor(textStrokeColor.cgColor)
context.setFillColor(textStrokeColor.cgColor)
context.setLineWidth(textStrokeWidth)
context.setTextDrawingMode(.fillStroke)
}
let textMatrix = context.textMatrix
let textPosition = context.textPosition
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
for i in 0 ..< layout.lines.count {
let line = layout.lines[i]
var lineFrame = line.frame
lineFrame.origin.y += offset.y
if alignment == .center {
lineFrame.origin.x = offset.x + floor((bounds.size.width - lineFrame.width) / 2.0)
} else if alignment == .natural {
if line.isRTL {
lineFrame.origin.x = offset.x + floor(bounds.size.width - lineFrame.width)
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: bounds.size), cutout: layout.cutout)
} else {
lineFrame.origin.x += offset.x
}
}
//context.setStrokeColor(UIColor.red.cgColor)
//context.stroke(lineFrame.offsetBy(dx: 0.0, dy: -lineFrame.height))
lineFrame.origin.y += -line.descent
context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.minY)
if layout.displaySpoilers && !line.spoilers.isEmpty {
context.saveGState()
var clipRects: [CGRect] = []
for spoiler in line.spoilerWords {
var spoilerClipRect = spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY - UIScreenPixel)
spoilerClipRect.size.height += 1.0 + UIScreenPixel
clipRects.append(spoilerClipRect)
}
context.clip(to: clipRects)
}
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
let hasAttachments = !line.attachments.isEmpty
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
let attributes = CTRunGetAttributes(run) as NSDictionary
if attributes["Attribute__EmbeddedItem"] != nil {
continue
}
var fixDoubleEmoji = false
if glyphCount == 2, let font = attributes["NSFont"] as? UIFont, font.fontName.contains("ColorEmoji"), let string = layout.attributedString {
let range = CTRunGetStringRange(run)
if range.location < string.length && (range.location + range.length) <= string.length {
let substring = string.attributedSubstring(from: NSMakeRange(range.location, range.length)).string
let heart = Unicode.Scalar(0x2764)!
let man = Unicode.Scalar(0x1F468)!
let woman = Unicode.Scalar(0x1F469)!
let leftHand = Unicode.Scalar(0x1FAF1)!
let rightHand = Unicode.Scalar(0x1FAF2)!
if substring.unicodeScalars.contains(heart) && (substring.unicodeScalars.contains(man) || substring.unicodeScalars.contains(woman)) {
fixDoubleEmoji = true
} else if substring.unicodeScalars.contains(leftHand) && substring.unicodeScalars.contains(rightHand) {
fixDoubleEmoji = true
}
}
}
if fixDoubleEmoji {
context.setBlendMode(.normal)
}
if hasAttachments {
let stringRange = CTRunGetStringRange(run)
if line.attachments.contains(where: { $0.range.contains(stringRange.location) }) {
} else {
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
} else {
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
if fixDoubleEmoji {
context.setBlendMode(blendMode)
}
}
}
for attachment in line.attachments {
let image = attachment.attachment
var textColor: UIColor?
layout.attributedString?.enumerateAttributes(in: attachment.range, options: []) { attributes, range, _ in
if let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
textColor = color
}
}
if let textColor {
if let tintedImage = generateTintedImage(image: image, color: textColor) {
let imageRect = CGRect(origin: CGPoint(x: attachment.frame.midX - tintedImage.size.width * 0.5, y: attachment.frame.midY - tintedImage.size.height * 0.5 + 1.0), size: tintedImage.size).offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)
context.translateBy(x: imageRect.midX, y: imageRect.midY)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -imageRect.midX, y: -imageRect.midY)
context.draw(tintedImage.cgImage!, in: imageRect)
context.translateBy(x: imageRect.midX, y: imageRect.midY)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -imageRect.midX, y: -imageRect.midY)
}
}
}
if !line.strikethroughs.isEmpty {
for strikethrough in line.strikethroughs {
guard let lineRange = line.range else {
continue
}
var textColor: UIColor?
layout.attributedString?.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in
if range == strikethrough.range, let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
textColor = color
}
}
if let textColor = textColor {
context.setFillColor(textColor.cgColor)
}
let frame = strikethrough.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)
context.fill(CGRect(x: frame.minX, y: frame.minY - 5.0, width: frame.width, height: 1.0))
}
}
if !line.spoilers.isEmpty {
if layout.displaySpoilers {
context.restoreGState()
} else {
for spoiler in line.spoilerWords {
var spoilerClearRect = spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY - UIScreenPixel)
spoilerClearRect.size.height += 1.0 + UIScreenPixel
clearRects.append(spoilerClearRect)
}
}
}
if let (additionalTrailingLine, _) = line.additionalTrailingLine {
context.textPosition = CGPoint(x: lineFrame.maxX, y: lineFrame.minY)
let glyphRuns = CTLineGetGlyphRuns(additionalTrailingLine) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
let attributes = CTRunGetAttributes(run) as NSDictionary
if attributes["Attribute__EmbeddedItem"] != nil {
continue
}
var fixDoubleEmoji = false
if glyphCount == 2, let font = attributes["NSFont"] as? UIFont, font.fontName.contains("ColorEmoji"), let string = layout.attributedString {
let range = CTRunGetStringRange(run)
if range.location < string.length && (range.location + range.length) <= string.length {
let substring = string.attributedSubstring(from: NSMakeRange(range.location, range.length)).string
let heart = Unicode.Scalar(0x2764)!
let man = Unicode.Scalar(0x1F468)!
let woman = Unicode.Scalar(0x1F469)!
let leftHand = Unicode.Scalar(0x1FAF1)!
let rightHand = Unicode.Scalar(0x1FAF2)!
if substring.unicodeScalars.contains(heart) && (substring.unicodeScalars.contains(man) || substring.unicodeScalars.contains(woman)) {
fixDoubleEmoji = true
} else if substring.unicodeScalars.contains(leftHand) && substring.unicodeScalars.contains(rightHand) {
fixDoubleEmoji = true
}
}
}
if fixDoubleEmoji {
context.setBlendMode(.normal)
}
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
if fixDoubleEmoji {
context.setBlendMode(blendMode)
}
}
}
}
}
context.textMatrix = textMatrix
context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y)
}
context.setBlendMode(.normal)
for rect in clearRects {
context.clear(rect)
}
}
public static func asyncLayout(_ maybeNode: TextNode?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode) {
let existingLayout: TextNodeLayout? = maybeNode?.cachedLayout
return { arguments in
let layout: TextNodeLayout
var updated = false
if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.explicitAlignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) {
let stringMatch: Bool
var colorMatch: Bool = true
if let backgroundColor = arguments.backgroundColor, let previousBackgroundColor = existingLayout.backgroundColor {
if !backgroundColor.isEqual(previousBackgroundColor) {
colorMatch = false
}
} else if (arguments.backgroundColor != nil) != (existingLayout.backgroundColor != nil) {
colorMatch = false
}
if !colorMatch {
stringMatch = false
} else if let existingString = existingLayout.attributedString, let string = arguments.attributedString {
stringMatch = existingString.isEqual(to: string)
} else if existingLayout.attributedString == nil && arguments.attributedString == nil {
stringMatch = true
} else {
stringMatch = false
}
if stringMatch {
layout = existingLayout
} else {
layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken)
updated = true
}
} else {
layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken)
updated = true
}
let node = maybeNode ?? TextNode()
return (layout, {
node.cachedLayout = layout
if updated {
if layout.size.width.isZero || layout.size.height.isZero {
node.contents = nil
}
node.setNeedsDisplay()
}
return node
})
}
}
}
open class TextView: UIView {
public internal(set) var cachedLayout: TextNodeLayout?
override public init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
self.isOpaque = false
self.clipsToBounds = false
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.attributesAtPoint(point, orNearest: orNearest)
} else {
return nil
}
}
public func textRangesRects(text: String) -> [[CGRect]] {
return self.cachedLayout?.textRangesRects(text: text) ?? []
}
public func attributeSubstring(name: String, index: Int) -> (String, String)? {
return self.cachedLayout?.attributeSubstring(name: name, index: index)
}
public func attributeRects(name: String, at index: Int) -> [CGRect]? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.lineAndAttributeRects(name: name, at: index)?.map { $0.1 }
} else {
return nil
}
}
public func rangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.rangeRects(in: range)
} else {
return nil
}
}
public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? {
if let cachedLayout = self.cachedLayout {
return cachedLayout.lineAndAttributeRects(name: name, at: index)
} else {
return nil
}
}
private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout {
if let attributedString = attributedString {
let stringLength = attributedString.length
let font: CTFont
let resolvedAlignment: NSTextAlignment
if stringLength != 0 {
if let stringFont = attributedString.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) {
font = stringFont as! CTFont
} else {
font = defaultFont
}
if alignment == .center {
resolvedAlignment = .center
} else {
if let paragraphStyle = attributedString.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle {
resolvedAlignment = paragraphStyle.alignment
} else {
resolvedAlignment = alignment
}
}
} else {
font = defaultFont
resolvedAlignment = alignment
}
let fontAscent = CTFontGetAscent(font)
let fontDescent = CTFontGetDescent(font)
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
var lines: [TextNodeLine] = []
let blockQuotes: [TextNodeBlockQuote] = []
var maybeTypesetter: CTTypesetter?
maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString)
if maybeTypesetter == nil {
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers)
}
let typesetter = maybeTypesetter!
var lastLineCharacterIndex: CFIndex = 0
var layoutSize = CGSize()
var cutoutEnabled = false
var cutoutMinY: CGFloat = 0.0
var cutoutMaxY: CGFloat = 0.0
var cutoutWidth: CGFloat = 0.0
var cutoutOffset: CGFloat = 0.0
var bottomCutoutEnabled = false
var bottomCutoutSize = CGSize()
if let topLeft = cutout?.topLeft {
cutoutMinY = -fontLineSpacing
cutoutMaxY = topLeft.height + fontLineSpacing
cutoutWidth = topLeft.width
cutoutOffset = cutoutWidth
cutoutEnabled = true
} else if let topRight = cutout?.topRight {
cutoutMinY = -fontLineSpacing
cutoutMaxY = topRight.height + fontLineSpacing
cutoutWidth = topRight.width
cutoutEnabled = true
}
if let bottomRight = cutout?.bottomRight {
bottomCutoutSize = bottomRight
bottomCutoutEnabled = true
}
let firstLineOffset = floorToScreenPixels(fontDescent)
var truncated = false
var first = true
while true {
var strikethroughs: [TextNodeStrikethrough] = []
var spoilers: [TextNodeSpoiler] = []
var spoilerWords: [TextNodeSpoiler] = []
var attachments: [TextNodeAttachment] = []
var lineConstrainedWidth = constrainedSize.width
var lineConstrainedWidthDelta: CGFloat = 0.0
var lineOriginY = floorToScreenPixels(layoutSize.height + fontAscent)
if !first {
lineOriginY += fontLineSpacing
}
var lineCutoutOffset: CGFloat = 0.0
var lineAdditionalWidth: CGFloat = 0.0
if cutoutEnabled {
if lineOriginY - fontLineHeight < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY {
lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth)
lineConstrainedWidthDelta = -cutoutWidth
lineCutoutOffset = cutoutOffset
lineAdditionalWidth = cutoutWidth
}
}
let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth))
func addSpoiler(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
spoilers.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent)))
}
func addSpoilerWord(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
spoilerWords.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent)))
}
func addAttachment(attachment: UIImage, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) {
var secondaryLeftOffset: CGFloat = 0.0
let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset)
var leftOffset = floor(rawLeftOffset)
if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
leftOffset = floor(secondaryLeftOffset)
}
var secondaryRightOffset: CGFloat = 0.0
let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset)
var rightOffset = ceil(rawRightOffset)
if !rawRightOffset.isEqual(to: secondaryRightOffset) {
rightOffset = ceil(secondaryRightOffset)
}
attachments.append(TextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment))
}
var isLastLine = false
if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 {
isLastLine = true
} else if layoutSize.height + (fontLineSpacing + fontLineHeight) * 2.0 > constrainedSize.height {
isLastLine = true
}
if isLastLine {
if first {
first = false
} else {
layoutSize.height += fontLineSpacing
}
let lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex)
var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount)
if brokenLineRange.location + brokenLineRange.length > attributedString.length {
brokenLineRange.length = attributedString.length - brokenLineRange.location
}
if lineRange.length == 0 {
break
}
let coreTextLine: CTLine
let originalLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 0.0)
var lineConstrainedSize = constrainedSize
lineConstrainedSize.width += lineConstrainedWidthDelta
if bottomCutoutEnabled {
lineConstrainedSize.width -= bottomCutoutSize.width
}
if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(lineConstrainedSize.width) {
coreTextLine = originalLine
} else {
var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:]
truncationTokenAttributes[NSAttributedString.Key.font] = font
truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber
let tokenString = "\u{2026}"
let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes)
let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString)
coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(lineConstrainedSize.width), truncationType, truncationToken) ?? truncationToken
let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun]
for run in runs {
let runAttributes: NSDictionary = CTRunGetAttributes(run)
if let _ = runAttributes["CTForegroundColorFromContext"] {
brokenLineRange.length = CTRunGetStringRange(run).location
break
}
}
if brokenLineRange.location + brokenLineRange.length > attributedString.length {
brokenLineRange.length = attributedString.length - brokenLineRange.location
}
truncated = true
}
var headIndent: CGFloat = 0.0
if brokenLineRange.location >= 0 && brokenLineRange.length > 0 && brokenLineRange.location + brokenLineRange.length <= attributedString.length {
attributedString.enumerateAttributes(in: NSMakeRange(brokenLineRange.location, brokenLineRange.length), options: []) { attributes, range, _ in
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedString.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: truncated ? 12.0 : 0.0)
}
addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
} else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight)))
} else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle {
headIndent = paragraphStyle.headIndent
}
if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
}
}
}
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))))
let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight)
layoutSize.height += fontLineHeight + fontLineSpacing
layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth)
if headIndent > 0.0 {
//blockQuotes.append(TextNodeBlockQuote(frame: lineFrame))
}
var isRTL = false
let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray
if glyphRuns.count != 0 {
let run = glyphRuns[0] as! CTRun
if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
}
lines.append(TextNodeLine(
line: coreTextLine,
frame: lineFrame,
ascent: lineAscent,
descent: lineDescent,
range: NSMakeRange(lineRange.location, lineRange.length),
isRTL: isRTL,
strikethroughs: strikethroughs,
spoilers: spoilers,
spoilerWords: spoilerWords,
embeddedItems: [],
attachments: attachments,
additionalTrailingLine: nil
))
break
} else {
if lineCharacterCount > 0 {
if first {
first = false
} else {
layoutSize.height += fontLineSpacing
}
var lineRange = CFRangeMake(lastLineCharacterIndex, lineCharacterCount)
if lineRange.location + lineRange.length > attributedString.length {
lineRange.length = attributedString.length - lineRange.location
}
if lineRange.length < 0 {
break
}
let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 100.0)
lastLineCharacterIndex += lineCharacterCount
var headIndent: CGFloat = 0.0
attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedString.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex)
}
addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
} else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight)))
} else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle {
headIndent = paragraphStyle.headIndent
}
if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage {
var ascent: CGFloat = 0.0
var descent: CGFloat = 0.0
CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil)
addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length)
}
}
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))
let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight)
layoutSize.height += fontLineHeight
layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth)
if headIndent > 0.0 {
//blockQuotes.append(TextNodeBlockQuote(frame: lineFrame))
}
var isRTL = false
let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray
if glyphRuns.count != 0 {
let run = glyphRuns[0] as! CTRun
if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
}
lines.append(TextNodeLine(
line: coreTextLine,
frame: lineFrame,
ascent: lineAscent,
descent: lineDescent,
range: NSMakeRange(lineRange.location, lineRange.length),
isRTL: isRTL,
strikethroughs: strikethroughs,
spoilers: spoilers,
spoilerWords: spoilerWords,
embeddedItems: [],
attachments: attachments,
additionalTrailingLine: nil
))
} else {
if !lines.isEmpty {
layoutSize.height += fontLineSpacing
}
break
}
}
}
let rawLayoutSize = layoutSize
if !lines.isEmpty && bottomCutoutEnabled {
let proposedWidth = lines[lines.count - 1].frame.width + bottomCutoutSize.width
if proposedWidth > layoutSize.width {
if proposedWidth <= constrainedSize.width + .ulpOfOne {
layoutSize.width = proposedWidth
} else {
layoutSize.height += bottomCutoutSize.height
}
}
}
if lines.count < minimumNumberOfLines {
var lineCount = lines.count
while lineCount < minimumNumberOfLines {
if lineCount != 0 {
layoutSize.height += fontLineSpacing
}
layoutSize.height += fontLineHeight
lineCount += 1
}
}
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers)
} else {
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers)
}
}
public override func draw(_ rect: CGRect) {
let bounds = self.bounds
let layout = self.cachedLayout
let context = UIGraphicsGetCurrentContext()!
context.setAllowsAntialiasing(true)
context.setAllowsFontSmoothing(false)
context.setShouldSmoothFonts(false)
context.setAllowsFontSubpixelPositioning(false)
context.setShouldSubpixelPositionFonts(false)
context.setAllowsFontSubpixelQuantization(true)
context.setShouldSubpixelQuantizeFonts(true)
var clearRects: [CGRect] = []
if let layout = layout {
if layout.backgroundColor != nil {
context.setBlendMode(.copy)
context.setFillColor((layout.backgroundColor ?? UIColor.clear).cgColor)
context.fill(bounds)
context.setBlendMode(.copy)
}
if let textShadowColor = layout.textShadowColor {
context.setTextDrawingMode(.fill)
context.setShadow(offset: layout.textShadowBlur != nil ? .zero : CGSize(width: 0.0, height: 1.0), blur: layout.textShadowBlur ?? 0.0, color: textShadowColor.cgColor)
}
if let (textStrokeColor, textStrokeWidth) = layout.textStroke {
context.setBlendMode(.normal)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setStrokeColor(textStrokeColor.cgColor)
context.setFillColor(textStrokeColor.cgColor)
context.setLineWidth(textStrokeWidth)
context.setTextDrawingMode(.fillStroke)
}
let textMatrix = context.textMatrix
let textPosition = context.textPosition
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
let alignment = layout.resolvedAlignment
var offset = CGPoint(x: layout.insets.left, y: layout.insets.top)
switch layout.verticalAlignment {
case .top:
break
case .middle:
offset.y = floor((bounds.height - layout.size.height) / 2.0) + layout.insets.top
case .bottom:
offset.y = floor(bounds.height - layout.size.height) + layout.insets.top
}
for i in 0 ..< layout.lines.count {
let line = layout.lines[i]
var lineFrame = line.frame
lineFrame.origin.y += offset.y
if alignment == .center {
lineFrame.origin.x = offset.x + floor((bounds.size.width - lineFrame.width) / 2.0)
} else if alignment == .natural, line.isRTL {
lineFrame.origin.x = offset.x + floor(bounds.size.width - lineFrame.width)
lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: bounds.size), cutout: layout.cutout)
}
context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.minY)
if layout.displaySpoilers && !line.spoilers.isEmpty {
context.saveGState()
var clipRects: [CGRect] = []
for spoiler in line.spoilerWords {
var spoilerClipRect = spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY - UIScreenPixel)
spoilerClipRect.size.height += 1.0 + UIScreenPixel
clipRects.append(spoilerClipRect)
}
context.clip(to: clipRects)
}
if line.attachments.isEmpty {
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
}
} else {
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let stringRange = CTRunGetStringRange(run)
if line.attachments.contains(where: { $0.range.contains(stringRange.location) }) {
continue
}
let glyphCount = CTRunGetGlyphCount(run)
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
}
}
for attachment in line.attachments {
let image = attachment.attachment
var textColor: UIColor?
layout.attributedString?.enumerateAttributes(in: attachment.range, options: []) { attributes, range, _ in
if let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
textColor = color
}
}
if let textColor {
if let tintedImage = generateTintedImage(image: image, color: textColor) {
let imageRect = CGRect(origin: CGPoint(x: attachment.frame.midX - tintedImage.size.width * 0.5, y: attachment.frame.midY - tintedImage.size.height * 0.5 + 1.0), size: tintedImage.size).offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)
context.draw(tintedImage.cgImage!, in: imageRect)
}
}
}
if !line.spoilers.isEmpty {
if layout.displaySpoilers {
context.restoreGState()
} else {
for spoiler in line.spoilerWords {
var spoilerClearRect = spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY - UIScreenPixel)
spoilerClearRect.size.height += 1.0 + UIScreenPixel
clearRects.append(spoilerClearRect)
}
}
}
}
var blockQuoteFrames: [CGRect] = []
var currentBlockQuoteFrame: CGRect?
for blockQuote in layout.blockQuotes {
if let frame = currentBlockQuoteFrame {
if blockQuote.frame.minY - frame.maxY < 20.0 {
currentBlockQuoteFrame = frame.union(blockQuote.frame)
} else {
blockQuoteFrames.append(frame)
currentBlockQuoteFrame = frame
}
} else {
currentBlockQuoteFrame = blockQuote.frame
}
}
if let frame = currentBlockQuoteFrame {
blockQuoteFrames.append(frame)
}
for frame in blockQuoteFrames {
if let lineColor = layout.lineColor {
context.setFillColor(lineColor.cgColor)
}
let rect = UIBezierPath(roundedRect: CGRect(x: frame.minX - 9.0, y: frame.minY - 14.0, width: 2.0, height: frame.height), cornerRadius: 1.0)
context.addPath(rect.cgPath)
context.fillPath()
}
context.textMatrix = textMatrix
context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y)
}
context.setBlendMode(.normal)
for rect in clearRects {
context.clear(rect)
}
}
public static func asyncLayout(_ maybeView: TextView?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextView) {
let existingLayout: TextNodeLayout? = maybeView?.cachedLayout
return { arguments in
let layout: TextNodeLayout
var updated = false
if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.explicitAlignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) {
let stringMatch: Bool
var colorMatch: Bool = true
if let backgroundColor = arguments.backgroundColor, let previousBackgroundColor = existingLayout.backgroundColor {
if !backgroundColor.isEqual(previousBackgroundColor) {
colorMatch = false
}
} else if (arguments.backgroundColor != nil) != (existingLayout.backgroundColor != nil) {
colorMatch = false
}
if !colorMatch {
stringMatch = false
} else if let existingString = existingLayout.attributedString, let string = arguments.attributedString {
stringMatch = existingString.isEqual(to: string)
} else if existingLayout.attributedString == nil && arguments.attributedString == nil {
stringMatch = true
} else {
stringMatch = false
}
if stringMatch {
layout = existingLayout
} else {
layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken)
updated = true
}
} else {
layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken)
updated = true
}
let view = maybeView ?? TextView()
return (layout, {
view.cachedLayout = layout
if updated {
if layout.size.width.isZero && layout.size.height.isZero {
view.layer.contents = nil
}
view.setNeedsDisplay()
}
return view
})
}
}
}