// // DTCoreTextLayoutFrame.m // WeRead // // Reverse-engineered from binary analysis // Based on DTCoreText open-source library, customized by WeRead // // This file contains pseudo-code reconstruction of the DTCoreTextLayoutFrame // class based on ivar analysis, method signatures, and the open-source DTCoreText library. // #import "DTCoreTextLayoutFrame.h" #import "DTCoreTextLayoutLine.h" #import "DTCoreTextGlyphRun.h" #import #pragma mark - Private Interface @interface DTCoreTextLayoutFrame () { // The CoreText frame object CTFrameRef _ctFrame; // Cached layout lines NSArray *_cachedLines; // Cached glyph runs NSArray *_cachedGlyphRuns; // Cached line origins NSArray *_cachedLineOrigins; // Whether lines have been extracted BOOL _linesExtracted; // Whether glyph runs have been extracted BOOL _glyphRunsExtracted; // Internal lock NSLock *_lock; // Cached content height CGFloat _cachedContentHeight; BOOL _contentHeightCached; // Visible string range NSRange _visibleStringRange; // Whether the frame is truncated BOOL _isTruncated; // Truncation range NSRange _truncatedRange; } @end #pragma mark - DTCoreTextLayoutFrame Implementation @implementation DTCoreTextLayoutFrame #pragma mark - Lifecycle - (instancetype)initWithAttributedString:(NSAttributedString *)attributedString range:(NSRange)range ctFrame:(CTFrameRef)ctFrame { self = [super init]; if (self) { _attributedString = attributedString; _range = range; _lock = [[NSLock alloc] init]; // Store the CTFrame if (ctFrame) { _ctFrame = (CTFrameRef)CFRetain(ctFrame); } // Initialize state _linesExtracted = NO; _glyphRunsExtracted = NO; _contentHeightCached = NO; _needsLayout = YES; // Get the frame path bounds if (_ctFrame) { CGPathRef path = CTFrameGetPath(_ctFrame); _frame = CGPathGetPathBoundingBox(path); } // Initialize visible range to full range _visibleStringRange = range; } return self; } - (void)dealloc { if (_ctFrame) { CFRelease(_ctFrame); _ctFrame = NULL; } } #pragma mark - Line Extraction /** * Extracts layout lines from the CTFrame. * * This method iterates through the CTFrame's lines and creates * DTCoreTextLayoutLine wrapper objects with position information. * * Algorithm: * 1. Get array of CTLines from CTFrame * 2. For each CTLine: * a. Get its origin from CTFrameGetLineOrigins * b. Get the string range from CTLineGetStringRange * c. Create DTCoreTextLayoutLine wrapper * 3. Cache the results */ - (void)extractLines { if (_linesExtracted || !_ctFrame) { return; } [_lock lock]; // Get the lines from the CTFrame CFArrayRef ctLines = CTFrameGetLines(_ctFrame); if (!ctLines) { _cachedLines = @[]; _linesExtracted = YES; [_lock unlock]; return; } CFIndex lineCount = CFArrayGetCount(ctLines); if (lineCount == 0) { _cachedLines = @[]; _linesExtracted = YES; [_lock unlock]; return; } // Get line origins CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount); CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins); NSMutableArray *lines = [NSMutableArray arrayWithCapacity:lineCount]; for (CFIndex i = 0; i < lineCount; i++) { CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i); // Get the string range for this line CFRange cfRange = CTLineGetStringRange(ctLine); NSRange range = NSMakeRange(cfRange.location, cfRange.length); // Create layout line wrapper DTCoreTextLayoutLine *layoutLine = [[DTCoreTextLayoutLine alloc] initWithCTLine:ctLine origin:origins[i] range:range index:i]; [lines addObject:layoutLine]; } free(origins); _cachedLines = [lines copy]; _cachedLineOrigins = nil; // Invalidate origin cache _linesExtracted = YES; [_lock unlock]; } /** * Returns the array of layout lines. * Triggers extraction if not already done. */ - (NSArray *)lines { [self extractLines]; return _cachedLines; } - (NSUInteger)numberOfLines { return [[self lines] count]; } - (DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index { NSArray *lines = [self lines]; if (index < [lines count]) { return lines[index]; } return nil; } /** * Returns the line that contains the given string index. */ - (DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index { NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSRange range = line.stringRange; if (index >= range.location && index < range.location + range.length) { return line; } } return nil; } /** * Returns the index of the line containing the given point. */ - (NSUInteger)lineIndexContainingPoint:(CGPoint)point { NSArray *allLines = [self lines]; for (NSUInteger i = 0; i < [allLines count]; i++) { DTCoreTextLayoutLine *line = allLines[i]; // Check if point is within the line's vertical bounds CGFloat lineTop = line.origin.y + line.ascent; CGFloat lineBottom = line.origin.y - line.descent; if (point.y >= lineBottom && point.y <= lineTop) { return i; } } return NSNotFound; } #pragma mark - Glyph Run Extraction /** * Extracts glyph runs from all lines. * * This method iterates through all lines and extracts their glyph runs, * creating a flat array of all glyph runs in the frame. */ - (void)extractGlyphRuns { if (_glyphRunsExtracted) { return; } [_lock lock]; NSArray *allLines = [self lines]; NSMutableArray *allRuns = [NSMutableArray array]; for (DTCoreTextLayoutLine *line in allLines) { NSArray *lineRuns = [line glyphRuns]; [allRuns addObjectsFromArray:lineRuns]; } _cachedGlyphRuns = [allRuns copy]; _glyphRunsExtracted = YES; [_lock unlock]; } /** * Returns all glyph runs in the frame. */ - (NSArray *)glyphRuns { [self extractGlyphRuns]; return _cachedGlyphRuns; } #pragma mark - Drawing /** * Draws the layout frame content into a CGContext. * * This method draws the text content using CTFrameDraw. * * @param context The CGContext to draw into */ - (void)drawInContext:(CGContextRef)context { if (!context || !_ctFrame) { return; } [_lock lock]; // Save graphics state CGContextSaveGState(context); // CoreText uses bottom-left origin, UIKit uses top-left // We need to flip the coordinate system CGContextTranslateCTM(context, 0, _frame.size.height); CGContextScaleCTM(context, 1.0, -1.0); // Draw the frame CTFrameDraw(_ctFrame, context); // Restore graphics state CGContextRestoreGState(context); [_lock unlock]; } /** * Draws the layout frame with options. * * @param context The CGContext to draw into * @param options Drawing options dictionary */ - (void)drawInContext:(CGContextRef)context options:(NSDictionary *)options { if (!context || !_ctFrame) { return; } [_lock lock]; // Save graphics state CGContextSaveGState(context); // Apply coordinate transformation CGContextTranslateCTM(context, 0, _frame.size.height); CGContextScaleCTM(context, 1.0, -1.0); // Check for custom drawing options BOOL drawImages = [options[@"drawImages"] boolValue]; BOOL drawLinks = [options[@"drawLinks"] boolValue]; BOOL drawSelection = [options[@"drawSelection"] boolValue]; // Draw the frame CTFrameDraw(_ctFrame, context); // Draw images if requested if (drawImages) { [self drawImagesInContext:context options:options]; } // Draw links if requested if (drawLinks) { [self drawLinksInContext:context options:options]; } // Draw selection if requested if (drawSelection) { [self drawSelectionInContext:context options:options]; } // Restore graphics state CGContextRestoreGState(context); [_lock unlock]; } /** * Draws images in the layout frame. */ - (void)drawImagesInContext:(CGContextRef)context options:(NSDictionary *)options { // Extract image attachments from attributed string NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSArray *runs = [line glyphRuns]; for (DTCoreTextGlyphRun *run in runs) { NSDictionary *attributes = run.attributes; // Check for attachment NSTextAttachment *attachment = attributes[@"NSAttachment"]; if (attachment && attachment.image) { CGRect runFrame = run.frame; // Draw the image at the run's position CGContextDrawImage(context, runFrame, attachment.image.CGImage); } } } } /** * Draws link indicators. */ - (void)drawLinksInContext:(CGContextRef)context options:(NSDictionary *)options { // Draw underlines for links NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSArray *runs = [line glyphRuns]; for (DTCoreTextGlyphRun *run in runs) { NSDictionary *attributes = run.attributes; NSURL *linkURL = attributes[@"NSLink"]; if (linkURL) { // Draw underline for link CGRect runFrame = run.frame; CGFloat underlineY = runFrame.origin.y; CGContextSetStrokeColorWithColor(context, [UIColor blueColor].CGColor); CGContextSetLineWidth(context, 1.0); CGContextMoveToPoint(context, runFrame.origin.x, underlineY); CGContextAddLineToPoint(context, CGRectGetMaxX(runFrame), underlineY); CGContextStrokePath(context); } } } } /** * Draws selection highlight. */ - (void)drawSelectionInContext:(CGContextRef)context options:(NSDictionary *)options { NSArray *selectedRanges = options[@"selectedRanges"]; UIColor *selectionColor = options[@"selectionColor"] ?: [UIColor colorWithRed:0.0 green:0.47 blue:1.0 alpha:0.2]; for (NSValue *rangeValue in selectedRanges) { NSRange range = [rangeValue rangeValue]; // Get the rects for this range NSArray *rects = [self rectsForRange:range]; CGContextSetFillColorWithColor(context, selectionColor.CGColor); for (NSValue *rectValue in rects) { CGRect rect = [rectValue CGRectValue]; CGContextFillRect(context, rect); } } } /** * Returns rects for a string range. */ - (NSArray *)rectsForRange:(NSRange)range { NSMutableArray *rects = [NSMutableArray array]; NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSRange lineRange = line.stringRange; NSRange intersection = NSIntersectionRange(range, lineRange); if (intersection.length > 0) { // Get the x positions for the range within this line CGFloat startX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location, NULL); CGFloat endX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location + intersection.length, NULL); CGRect lineRect = CGRectMake( line.origin.x + startX, line.origin.y - line.descent, endX - startX, line.height ); [rects addObject:[NSValue valueWithRect:lineRect]]; } } return [rects copy]; } #pragma mark - Hit Testing /** * Returns the string index at the given point. * * Uses CTLineGetStringIndexForPosition to find which character * is at the given point. * * @param point The point to test * @return The string index, or NSNotFound */ - (NSUInteger)stringIndexAtPoint:(CGPoint)point { NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { // Check if the point is within this line's vertical bounds CGFloat lineTop = line.origin.y + line.ascent; CGFloat lineBottom = line.origin.y - line.descent; if (point.y >= lineBottom && point.y <= lineTop) { // Use CTLine to find the character index CFIndex index = CTLineGetStringIndexForPosition(line.ctLine, point); if (index != kCFNotFound) { return (NSUInteger)index; } } } return NSNotFound; } /** * Returns the rect for the given string index. */ - (CGRect)rectForStringIndex:(NSUInteger)index { NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSRange range = line.stringRange; if (index >= range.location && index < range.location + range.length) { // Get the x position for this character CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL); return CGRectMake(line.origin.x + x, line.origin.y - line.descent, 1, line.height); } } return CGRectNull; } /** * Returns the cursor position for a given string index. */ - (CGRect)cursorRectForIndex:(NSUInteger)index { NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSRange range = line.stringRange; if (index >= range.location && index <= range.location + range.length) { CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL); return CGRectMake(line.origin.x + x - 1, line.origin.y - line.descent, 2, line.height); } } return CGRectNull; } #pragma mark - Text Geometry /** * Returns the baseline origin for a given string index. */ - (CGPoint)baselineOriginForStringIndex:(NSUInteger)index { NSArray *allLines = [self lines]; for (DTCoreTextLayoutLine *line in allLines) { NSRange range = line.stringRange; if (index >= range.location && index < range.location + range.length) { CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL); return CGPointMake(line.origin.x + x, line.origin.y); } } return CGPointZero; } /** * Returns the frame for a given string range. */ - (CGRect)frameForStringRange:(NSRange)range { NSArray *rects = [self rectsForRange:range]; if ([rects count] == 0) { return CGRectNull; } // Union all rects CGRect result = CGRectNull; for (NSValue *rectValue in rects) { result = CGRectUnion(result, [rectValue CGRectValue]); } return result; } /** * Returns the string range for a given rect. */ - (NSRange)stringRangeForRect:(CGRect)rect { NSArray *allLines = [self lines]; NSUInteger start = NSNotFound; NSUInteger end = NSNotFound; for (DTCoreTextLayoutLine *line in allLines) { CGRect lineRect = CGRectMake( line.origin.x, line.origin.y - line.descent, line.width, line.height ); if (CGRectIntersectsRect(rect, lineRect)) { if (start == NSNotFound) { start = line.stringRange.location; } end = line.stringRange.location + line.stringRange.length; } } if (start != NSNotFound && end != NSNotFound) { return NSMakeRange(start, end - start); } return NSMakeRange(NSNotFound, 0); } #pragma mark - Line Geometry /** * Returns the line origins. */ - (NSArray *)lineOrigins { if (_cachedLineOrigins) { return _cachedLineOrigins; } [_lock lock]; NSArray *allLines = [self lines]; NSMutableArray *origins = [NSMutableArray arrayWithCapacity:[allLines count]]; for (DTCoreTextLayoutLine *line in allLines) { [origins addObject:[NSValue valueWithCGPoint:line.origin]]; } _cachedLineOrigins = [origins copy]; [_lock unlock]; return _cachedLineOrigins; } /** * Returns the line frames. */ - (NSArray *)lineFrames { NSArray *allLines = [self lines]; NSMutableArray *frames = [NSMutableArray arrayWithCapacity:[allLines count]]; for (DTCoreTextLayoutLine *line in allLines) { CGRect lineFrame = CGRectMake( line.origin.x, line.origin.y - line.descent, line.width, line.height ); [frames addObject:[NSValue valueWithRect:lineFrame]]; } return [frames copy]; } #pragma mark - Content Height /** * Returns the rendered content height. */ - (CGFloat)contentHeight { [_lock lock]; if (_contentHeightCached) { [_lock unlock]; return _cachedContentHeight; } NSArray *allLines = [self lines]; if ([allLines count] == 0) { _cachedContentHeight = 0; _contentHeightCached = YES; [_lock unlock]; return 0; } // Find the lowest point of the last line DTCoreTextLayoutLine *lastLine = [allLines lastObject]; CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent; // Content height is from top of frame to bottom of last line _cachedContentHeight = _frame.size.height - lastLineBottom; _contentHeightCached = YES; [_lock unlock]; return _cachedContentHeight; } - (CGFloat)maximumY { NSArray *allLines = [self lines]; if ([allLines count] == 0) { return _frame.origin.y + _frame.size.height; } DTCoreTextLayoutLine *firstLine = [allLines firstObject]; return firstLine.origin.y + firstLine.ascent; } - (CGFloat)minimumY { NSArray *allLines = [self lines]; if ([allLines count] == 0) { return _frame.origin.y; } DTCoreTextLayoutLine *lastLine = [allLines lastObject]; return lastLine.origin.y - lastLine.descent; } #pragma mark - Truncation /** * Returns whether the frame is truncated. */ - (BOOL)isTruncated { // Check if the attributed string extends beyond the visible range NSUInteger stringEnd = _range.location + _range.length; NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length; return visibleEnd < stringEnd; } /** * Returns the truncation string range. */ - (NSRange)truncatedStringRange { if (![self isTruncated]) { return NSMakeRange(NSNotFound, 0); } NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length; NSUInteger stringEnd = _range.location + _range.length; return NSMakeRange(visibleEnd, stringEnd - visibleEnd); } #pragma mark - Layout Updates /** * Invalidates the layout, forcing recalculation on next access. */ - (void)invalidateLayout { [_lock lock]; _linesExtracted = NO; _glyphRunsExtracted = NO; _contentHeightCached = NO; _cachedLines = nil; _cachedGlyphRuns = nil; _cachedLineOrigins = nil; _needsLayout = YES; [_lock unlock]; } /** * Forces a layout pass. */ - (void)layoutIfNeeded { if (_needsLayout) { [self extractLines]; _needsLayout = NO; } } - (NSUInteger)stringIndex { return _range.location + _range.length; } @end