import UIKit #if canImport(DTCoreText) import DTCoreText #endif struct RDEPUBPageLine { let stringRange: NSRange let frame: CGRect let baselineOrigin: CGPoint let ascent: CGFloat let descent: CGFloat let leading: CGFloat } struct RDEPUBPageRun { let stringRange: NSRange let frame: CGRect let isAttachment: Bool } struct RDEPUBPageAttachment { let stringRange: NSRange let frame: CGRect let displaySize: CGSize let placement: RDEPUBTextAttachmentPlacement? let kind: RDEPUBTextAttachmentKind? } struct RDEPUBPageLayoutSnapshot { let page: RDEPUBTextPage let lines: [RDEPUBPageLine] let runs: [RDEPUBPageRun] let attachments: [RDEPUBPageAttachment] let pageContentRange: NSRange #if canImport(DTCoreText) let layoutFrame: DTCoreTextLayoutFrame #endif var contentBounds: CGRect { let rects = lines.map(\.frame) + attachments.map(\.frame) guard var bounds = rects.first else { return .zero } for rect in rects.dropFirst() { bounds = bounds.union(rect) } return bounds } func line(containing absoluteIndex: Int) -> RDEPUBPageLine? { lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) } } func run(containing absoluteIndex: Int) -> RDEPUBPageRun? { runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) } } func runs(intersecting range: NSRange) -> [RDEPUBPageRun] { runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 } } func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? { attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) } } func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? { lines.first { line in let lineRect = CGRect( x: line.frame.minX, y: line.baselineOrigin.y - line.ascent, width: max(line.frame.width, 1), height: line.ascent + line.descent + line.leading ) return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) } } func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? { runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) } } func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] { decorations.filter { decoration in decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) } } } func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? { if let attachment = attachment(at: point) { return attachment.stringRange } if let run = run(at: point) { return run.stringRange } let hitDecorations = rects(containing: point, in: decorations) if let mostSpecific = hitDecorations.min(by: { lhs, rhs in lhs.absoluteRange.length < rhs.absoluteRange.length }) { return mostSpecific.absoluteRange } return line(at: point)?.stringRange } #if canImport(DTCoreText) static func build( from layoutFrame: DTCoreTextLayoutFrame, page: RDEPUBTextPage ) -> RDEPUBPageLayoutSnapshot? { guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else { return nil } var lines: [RDEPUBPageLine] = [] var runs: [RDEPUBPageRun] = [] var attachments: [RDEPUBPageAttachment] = [] let pageOffset = page.pageStartOffset for dtLine in dtLines { let lineRange = offset(dtLine.stringRange(), by: pageOffset) let line = RDEPUBPageLine( stringRange: lineRange, frame: dtLine.frame, baselineOrigin: dtLine.baselineOrigin, ascent: dtLine.ascent, descent: dtLine.descent, leading: dtLine.leading ) lines.append(line) if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] { for run in glyphRuns { let runRange = offset(run.stringRange(), by: pageOffset) let isAttachment = run.attachment != nil runs.append( RDEPUBPageRun( stringRange: runRange, frame: run.frame, isAttachment: isAttachment ) ) guard isAttachment else { continue } let metadata = attachmentMetadata( for: runRange, on: page ) attachments.append( RDEPUBPageAttachment( stringRange: runRange, frame: run.frame, displaySize: run.frame.size, placement: metadata.placement, kind: metadata.kind ) ) } } } let visibleRange = offset(layoutFrame.visibleStringRange(), by: pageOffset) return RDEPUBPageLayoutSnapshot( page: page, lines: lines, runs: runs, attachments: attachments, pageContentRange: visibleRange, layoutFrame: layoutFrame ) } private static func attachmentMetadata( for range: NSRange, on page: RDEPUBTextPage ) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) { guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else { return (nil, nil) } let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex) ? page.metadata.attachmentPlacements[attachmentIndex] : nil let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex) ? page.metadata.attachmentKinds[attachmentIndex] : nil let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind return (placement, resolvedKind) } private static func attachmentKind( at range: NSRange, on page: RDEPUBTextPage ) -> RDEPUBTextAttachmentKind? { guard range.location >= 0, range.location < page.chapterContent.length else { return nil } let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil) return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) } private static func offset(_ range: NSRange, by offset: Int) -> NSRange { guard range.location != NSNotFound else { return range } return NSRange(location: range.location + offset, length: range.length) } #endif }