// // WRCoreTextLayoutFrame.m // WeRead // // Reverse-engineered from binary analysis // Single page layout frame implementation // // This file contains pseudo-code reconstruction of the WRCoreTextLayoutFrame // class based on ivar analysis, method signatures, and behavioral context. // #import "WRCoreTextLayoutFrame.h" #import // Forward declarations for WeRead-specific classes @class WRBookCoverView; @class WRDiscover; @class RACSubject; #pragma mark - Internal Constants // Threshold for avoid-page-break-inside calculation static const CGFloat kPageBreakAvoidanceThreshold = 20.0; // Maximum lines to remove for page break avoidance static const NSUInteger kMaxLinesToRemove = 3; #pragma mark - WRCoreTextLayoutLine Implementation @implementation WRCoreTextLayoutLine - (instancetype)initWithCTLine:(CTLineRef)ctLine origin:(CGPoint)origin range:(NSRange)range { self = [super init]; if (self) { _ctLine = (CTLineRef)CFRetain(ctLine); _origin = origin; _stringRange = range; // Calculate typographic metrics CGFloat ascent, descent, leading; CTLineGetTypographicBounds(_ctLine, &ascent, &descent, &leading); _ascent = ascent; _descent = descent; _leading = leading; _height = ascent + descent + leading; _width = CTLineGetTypographicBounds(_ctLine, NULL, NULL, NULL); } return self; } - (void)dealloc { if (_ctLine) { CFRelease(_ctLine); _ctLine = NULL; } } - (BOOL)isLastLineInParagraph { // A line is the last in a paragraph if it ends with a newline // or if the next line starts a new paragraph CFRange cfRange = CTLineGetStringRange(_ctLine); if (cfRange.length == 0) return NO; NSUInteger endIndex = cfRange.location + cfRange.length - 1; // This would need access to the full attributed string to determine // For now, we use the stored property return _isLastLineInParagraph; } @end #pragma mark - Private Interface @interface WRCoreTextLayoutFrame () { // The CoreText frame object CTFrameRef _ctFrame; // Cached layout lines NSArray *_cachedLines; // Whether lines have been extracted BOOL _linesExtracted; // Render height cache CGFloat _cachedRenderHeight; BOOL _renderHeightCached; // Internal lock for thread safety NSLock *_frameLock; } @end #pragma mark - WRCoreTextLayoutFrame Implementation @implementation WRCoreTextLayoutFrame #pragma mark - Lifecycle - (instancetype)init { self = [super init]; if (self) { [self commonInit]; } return self; } - (instancetype)initWithCTFrame:(CTFrameRef)ctFrame range:(NSRange)range { self = [super init]; if (self) { [self commonInit]; [self setCTFrame:ctFrame range:range]; } return self; } /** * Common initialization - sets up internal state. */ - (void)commonInit { _frameLock = [[NSLock alloc] init]; _cachedLines = @[]; _linesExtracted = NO; _renderHeightCached = NO; _cachedRenderHeight = 0; _customAttributes = [NSMutableDictionary dictionary]; _visibleElements = [NSMutableArray array]; _accessibilityElements = [NSMutableArray array]; _computedValueCache = [NSMutableDictionary dictionary]; _selectedLineIndices = [NSMutableSet set]; _avoidPageBreakInside = NO; _numberOfColumns = 1; _columnGap = 20.0; _contentInsets = UIEdgeInsetsZero; } - (void)dealloc { if (_ctFrame) { CFRelease(_ctFrame); _ctFrame = NULL; } } #pragma mark - Property Accessors - (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range { [_frameLock lock]; if (_ctFrame) { CFRelease(_ctFrame); } _ctFrame = ctFrame ? (CTFrameRef)CFRetain(ctFrame) : NULL; _stringRange = range; // Invalidate cached data _linesExtracted = NO; _renderHeightCached = NO; _cachedLines = @[]; [_frameLock unlock]; } - (CTFrameRef)ctFrame { return _ctFrame; } - (void)setAvoidPageBreakInside:(BOOL)avoidPageBreakInside { _avoidPageBreakInside = avoidPageBreakInside; if (avoidPageBreakInside) { // When enabled, we need to check and potentially remove lines [self avoidPageBreakInsideByRemovingLastLinesIfNeeded]; } } #pragma mark - Line Extraction /** * Extracts layout lines from the CTFrame. * * This method iterates through the CTFrame's lines and creates * WRCoreTextLayoutLine 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 WRCoreTextLayoutLine wrapper * d. Determine if it's the last line in a paragraph * 3. Cache the results */ - (void)extractLines { if (_linesExtracted || !_ctFrame) { return; } [_frameLock lock]; // Get the lines from the CTFrame CFArrayRef ctLines = CTFrameGetLines(_ctFrame); if (!ctLines) { [_frameLock unlock]; return; } CFIndex lineCount = CFArrayGetCount(ctLines); if (lineCount == 0) { _cachedLines = @[]; _linesExtracted = YES; [_frameLock 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 WRCoreTextLayoutLine *layoutLine = [[WRCoreTextLayoutLine alloc] initWithCTLine:ctLine origin:origins[i] range:range]; // Determine if this is the last line in a paragraph // Check if the next line starts a new paragraph or if this is the last line if (i == lineCount - 1) { layoutLine.isLastLineInParagraph = YES; } else { // Check if the character after this line is a newline NSUInteger endOfLine = range.location + range.length; if (endOfLine < [_attributedString length]) { unichar nextChar = [[_attributedString string] characterAtIndex:endOfLine]; layoutLine.isLastLineInParagraph = (nextChar == '\n' || nextChar == '\r'); } } [lines addObject:layoutLine]; } free(origins); _cachedLines = [lines copy]; _linesExtracted = YES; [_frameLock unlock]; } /** * Returns the array of layout lines. * Triggers extraction if not already done. */ - (NSArray *)lines { [self extractLines]; return _cachedLines; } - (NSUInteger)lineCount { return [[self lines] count]; } - (WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index { NSArray *lines = [self lines]; if (index < [lines count]) { return lines[index]; } return nil; } #pragma mark - Height Calculation /** * Calculates the rendered content height. * * This method determines the actual height of the rendered content * by examining the position of the last line. The rendered height * may be less than the frame height if there's unused space at the bottom. * * Algorithm: * 1. Get all layout lines * 2. Find the lowest line (by origin.y - descent) * 3. Return that position as the rendered height * * @return The rendered content height in points */ - (CGFloat)getRenderHeight { [_frameLock lock]; if (_renderHeightCached) { [_frameLock unlock]; return _cachedRenderHeight; } NSArray *lines = [self lines]; if ([lines count] == 0) { _cachedRenderHeight = 0; _renderHeightCached = YES; [_frameLock unlock]; return 0; } // Find the lowest point of the last line // The last line's bottom edge (origin.y - descent) gives us the content height WRCoreTextLayoutLine *lastLine = [lines lastObject]; // In CoreText, the coordinate system is flipped (origin at bottom-left) // The line's origin.y is the baseline position // To get the bottom of the line, we subtract the descent CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent; // The rendered height is from the top of the frame to the bottom of the last line // Since CoreText uses bottom-left origin, we need to convert CGFloat frameHeight = _frame.size.height; _cachedRenderHeight = frameHeight - lastLineBottom; // Add content insets _cachedRenderHeight += _contentInsets.top + _contentInsets.bottom; _renderHeightCached = YES; [_frameLock unlock]; return _cachedRenderHeight; } /** * Returns the descent of the last line. * Used for precise baseline alignment at the bottom of the page. */ - (CGFloat)lastLineDescent { NSArray *lines = [self lines]; if ([lines count] == 0) { return 0; } WRCoreTextLayoutLine *lastLine = [lines lastObject]; return lastLine.descent; } #pragma mark - Drawing /** * Draws the layout frame content into a CGContext. * * This is the primary rendering method for the reading view. * It performs the following steps: * * 1. Save the graphics state * 2. Flip the coordinate system (UIKit vs CoreText) * 3. Apply position offset * 4. Draw text using CTFrameDraw * 5. Draw image attachments at their positions * 6. Draw decorative elements (strikethrough, underline, highlights) * 7. Restore the graphics state * * @param context The CGContext to draw into * @param image Optional image (used for cover pages) * @param size The size of the drawing area * @param rect The rectangle to draw within * @param position The position offset for multi-page layouts */ - (void)drawInContext:(CGContextRef)context image:(UIImage *)image size:(CGSize)size inRect:(CGRect)rect position:(CGPoint)position { if (!context || !_ctFrame) { return; } [_frameLock lock]; // Save graphics state before drawing CGContextSaveGState(context); // CoreText uses a bottom-left origin, UIKit uses top-left // We need to flip the coordinate system CGContextTranslateCTM(context, 0, rect.size.height); CGContextScaleCTM(context, 1.0, -1.0); // Apply the position offset (for multi-column or multi-page layouts) CGContextTranslateCTM(context, position.x, -position.y); // Apply content insets CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom); // Draw the optional image (for cover pages or special layouts) if (image) { [self drawCoverImage:image inContext:context rect:rect]; } // Draw the main text content CTFrameDraw(_ctFrame, context); // Draw image attachments [self drawAttachmentsInContext:context inRect:rect]; // Draw decorative elements [self drawDecorativeElementsInContext:context inRect:rect]; // Restore graphics state CGContextRestoreGState(context); [_frameLock unlock]; } /** * Draws only the text content without images. */ - (void)drawTextInContext:(CGContextRef)context inRect:(CGRect)rect { if (!context || !_ctFrame) { return; } [_frameLock lock]; CGContextSaveGState(context); // Flip coordinate system CGContextTranslateCTM(context, 0, rect.size.height); CGContextScaleCTM(context, 1.0, -1.0); // Apply content insets CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom); // Draw just the text CTFrameDraw(_ctFrame, context); CGContextRestoreGState(context); [_frameLock unlock]; } /** * Draws image attachments at their calculated positions. * * This method iterates through the attachment array and draws each * image at the position determined by the layout engine. * * The attachment positions are calculated during layout and stored * in the attachments array. Each attachment has: * - A position (origin point) * - A size * - An image reference */ - (void)drawAttachmentsInContext:(CGContextRef)context inRect:(CGRect)rect { if (!_attachments || [_attachments count] == 0) { return; } for (NSDictionary *attachment in _attachments) { UIImage *image = attachment[@"image"]; NSValue *positionValue = attachment[@"position"]; NSValue *sizeValue = attachment[@"size"]; if (!image || !positionValue || !sizeValue) { continue; } CGPoint imagePosition = [positionValue CGPointValue]; CGSize imageSize = [sizeValue CGSizeValue]; CGRect imageRect = CGRectMake(imagePosition.x, imagePosition.y, imageSize.width, imageSize.height); // Draw the image CGContextDrawImage(context, imageRect, image.CGImage); } } /** * Draws decorative elements like strikethrough and underline. */ - (void)drawDecorativeElementsInContext:(CGContextRef)context inRect:(CGRect)rect { // Draw strikethrough lines if (_strikethroughRanges) { [self drawStrikethroughInContext:context]; } // Draw underlines if (_underlineRanges) { [self drawUnderlineInContext:context]; } // Draw highlights if (_highlightRanges) { [self drawHighlightsInContext:context]; } } /** * Draws strikethrough lines for the specified ranges. */ - (void)drawStrikethroughInContext:(CGContextRef)context { NSArray *lines = [self lines]; for (NSValue *rangeValue in _strikethroughRanges) { NSRange range = [rangeValue rangeValue]; // Find lines that intersect with this range for (WRCoreTextLayoutLine *line in lines) { NSRange lineRange = line.stringRange; NSRange intersection = NSIntersectionRange(range, lineRange); if (intersection.length > 0) { // Get the x positions for the strikethrough CGFloat startX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location, NULL); CGFloat endX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location + intersection.length, NULL); // Strikethrough is at the middle of the line CGFloat y = line.origin.y + (line.ascent * 0.3); CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor); CGContextSetLineWidth(context, 1.0); CGContextMoveToPoint(context, line.origin.x + startX, y); CGContextAddLineToPoint(context, line.origin.x + endX, y); CGContextStrokePath(context); } } } } /** * Draws underlines for the specified ranges. */ - (void)drawUnderlineInContext:(CGContextRef)context { NSArray *lines = [self lines]; for (NSDictionary *underlineData in _underlineRanges) { NSRange range = [underlineData[@"range"] rangeValue]; UIColor *color = underlineData[@"color"] ?: [UIColor blueColor]; for (WRCoreTextLayoutLine *line in lines) { NSRange lineRange = line.stringRange; NSRange intersection = NSIntersectionRange(range, lineRange); if (intersection.length > 0) { CGFloat startX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location, NULL); CGFloat endX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location + intersection.length, NULL); // Underline is below the baseline CGFloat y = line.origin.y - line.descent; CGContextSetStrokeColorWithColor(context, color.CGColor); CGContextSetLineWidth(context, 1.0); CGContextMoveToPoint(context, line.origin.x + startX, y); CGContextAddLineToPoint(context, line.origin.x + endX, y); CGContextStrokePath(context); } } } } /** * Draws highlight backgrounds for the specified ranges. */ - (void)drawHighlightsInContext:(CGContextRef)context { NSArray *lines = [self lines]; for (NSDictionary *highlightData in _highlightRanges) { NSRange range = [highlightData[@"range"] rangeValue]; UIColor *color = highlightData[@"color"] ?: [UIColor yellowColor]; for (WRCoreTextLayoutLine *line in lines) { NSRange lineRange = line.stringRange; NSRange intersection = NSIntersectionRange(range, lineRange); if (intersection.length > 0) { CGFloat startX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location, NULL); CGFloat endX = CTLineGetOffsetForStringIndex( line.ctLine, intersection.location + intersection.length, NULL); // Highlight rect from descent to ascent CGFloat y = line.origin.y - line.descent; CGFloat height = line.ascent + line.descent; CGRect highlightRect = CGRectMake( line.origin.x + startX, y, endX - startX, height ); CGContextSetFillColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor); CGContextFillRect(context, highlightRect); } } } } /** * Draws a cover image for special pages. */ - (void)drawCoverImage:(UIImage *)image inContext:(CGContextRef)context rect:(CGRect)rect { if (!image) return; // Scale the image to fit the frame while maintaining aspect ratio CGSize imageSize = image.size; CGSize frameSize = rect.size; CGFloat widthRatio = frameSize.width / imageSize.width; CGFloat heightRatio = frameSize.height / imageSize.height; CGFloat scale = MIN(widthRatio, heightRatio); CGSize scaledSize = CGSizeMake(imageSize.width * scale, imageSize.height * scale); // Center the image in the frame CGFloat x = (frameSize.width - scaledSize.width) / 2; CGFloat y = (frameSize.height - scaledSize.height) / 2; CGRect imageRect = CGRectMake(x, y, scaledSize.width, scaledSize.height); CGContextDrawImage(context, imageRect, image.CGImage); } #pragma mark - Page Break Avoidance /** * Implements the CSS 'avoid-page-break-inside' property. * * When an element (like a table, code block, or list) has this property, * we need to ensure it doesn't break across pages. If it would break, * we remove the last few lines to make room for the element on the next page. * * Algorithm: * 1. Check if avoidPageBreakInside is enabled * 2. Find elements with this property in the current frame * 3. For each element, check if it would break * 4. If it would break, remove lines from the end until the element fits * 5. Return whether any lines were removed * * @return YES if lines were removed to avoid page break */ - (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded { if (!_avoidPageBreakInside) { return NO; } [_frameLock lock]; NSArray *lines = [self lines]; if ([lines count] == 0) { [_frameLock unlock]; return NO; } // Check if any line has the avoid-page-break-inside attribute // This would be set in the attributed string attributes BOOL needsRemoval = NO; NSUInteger linesToRemove = 0; // Iterate from the end to find lines that shouldn't be broken for (NSInteger i = [lines count] - 1; i >= 0; i--) { WRCoreTextLayoutLine *line = lines[i]; NSRange range = line.stringRange; // Check if this line is part of an element with avoid-page-break-inside if ([self shouldAvoidPageBreakInRange:range]) { // Check if the element starts before this line // If so, we need to remove this line and possibly more needsRemoval = YES; linesToRemove++; // Limit the number of lines we remove if (linesToRemove >= kMaxLinesToRemove) { break; } } else if (needsRemoval) { // We've found a line that doesn't need avoidance, stop break; } } if (needsRemoval && linesToRemove > 0) { // Remove the last N lines NSMutableArray *mutableLines = [_cachedLines mutableCopy]; [mutableLines removeObjectsInRange: NSMakeRange([mutableLines count] - linesToRemove, linesToRemove)]; _cachedLines = [mutableLines copy]; // Update the CTFrame to reflect the removal // This requires recreating the frame with a shorter range [self rebuildFrameWithoutLastLines:linesToRemove]; [_frameLock unlock]; return YES; } [_frameLock unlock]; return NO; } /** * Checks if a text range has the avoid-page-break-inside property. */ - (BOOL)shouldAvoidPageBreakInRange:(NSRange)range { if (!_attributedString || range.location >= [_attributedString length]) { return NO; } // Check the attributes at the start of the range NSDictionary *attrs = [_attributedString attributesAtIndex:range.location effectiveRange:NULL]; // Check for our custom avoid-page-break-inside attribute NSNumber *avoidBreak = attrs[@"WRAvoidPageBreakInside"]; if ([avoidBreak boolValue]) { return YES; } // Also check for block-level elements that shouldn't break NSString *blockType = attrs[@"WRBlockType"]; if ([blockType isEqualToString:@"table"] || [blockType isEqualToString:@"code"] || [blockType isEqualToString:@"list"] || [blockType isEqualToString:@"blockquote"]) { return YES; } return NO; } /** * Rebuilds the CTFrame without the last N lines. * This is used after avoidPageBreakInside removes lines. */ - (void)rebuildFrameWithoutLastLines:(NSUInteger)count { if (!_ctFrame || count == 0) { return; } // Get the current lines NSArray *lines = _cachedLines; if ([lines count] == 0) { return; } // Calculate the new end range WRCoreTextLayoutLine *lastLine = [lines lastObject]; NSUInteger newEnd = lastLine.stringRange.location + lastLine.stringRange.length; // Update the string range _stringRange = NSMakeRange(_stringRange.location, newEnd - _stringRange.location); // Note: In a real implementation, we would need to recreate the CTFrame // with the updated range. This requires access to the CTTypesetter. // For this reconstruction, we just update the range metadata. } #pragma mark - Hit Testing /** * Returns the character index at a given point. * * Uses CTLineGetStringIndexForPosition to find which character * is at the given point. This is used for text selection and * tap handling. * * @param point The point to test (in the frame's coordinate system) * @return The character index, or NSNotFound */ - (NSUInteger)characterIndexAtPoint:(CGPoint)point { NSArray *lines = [self lines]; for (WRCoreTextLayoutLine *line in lines) { // 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 line index at a given point. */ - (NSUInteger)lineIndexAtPoint:(CGPoint)point { NSArray *lines = [self lines]; for (NSUInteger i = 0; i < [lines count]; i++) { WRCoreTextLayoutLine *line = lines[i]; 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; } /** * Returns the rect for a character at the given index. */ - (CGRect)rectForCharacterAtIndex:(NSUInteger)index { NSArray *lines = [self lines]; for (WRCoreTextLayoutLine *line in lines) { 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, // Width of 1 character line.height); } } return CGRectNull; } #pragma mark - Selection /** * Returns the currently selected text. */ - (NSString *)selectedText { NSArray *ranges = [self selectedRanges]; if ([ranges count] == 0) { return nil; } NSMutableString *selectedText = [NSMutableString string]; for (NSValue *rangeValue in ranges) { NSRange range = [rangeValue rangeValue]; NSString *substring = [_attributedString.string substringWithRange:range]; [selectedText appendString:substring]; } return [selectedText copy]; } /** * Returns the ranges of selected text. */ - (NSArray *)selectedRanges { NSMutableArray *ranges = [NSMutableArray array]; // Build ranges from selected line indices NSArray *lines = [self lines]; for (NSUInteger i = 0; i < [lines count]; i++) { if ([_selectedLineIndices containsObject:@(i)]) { WRCoreTextLayoutLine *line = lines[i]; [ranges addObject:[NSValue valueWithRange:line.stringRange]]; } } return [ranges copy]; } /** * Selects text in the given range. */ - (void)selectTextInRange:(NSRange)range { NSArray *lines = [self lines]; for (NSUInteger i = 0; i < [lines count]; i++) { WRCoreTextLayoutLine *line = lines[i]; NSRange intersection = NSIntersectionRange(range, line.stringRange); if (intersection.length > 0) { [_selectedLineIndices addObject:@(i)]; } } // Notify via RACSubject [_layoutChangeSubject sendNext:@{ @"type": @"selection", @"range": [NSValue valueWithRange:range] }]; } /** * Clears the current selection. */ - (void)clearSelection { [_selectedLineIndices removeAllObjects]; [_layoutChangeSubject sendNext:@{ @"type": @"selectionCleared" }]; } #pragma mark - Search /** * Highlights search results within this frame. * * @param searchString The string to search for * @return The number of matches found */ - (NSUInteger)highlightSearchResults:(NSString *)searchString { if (!searchString || !_attributedString) { return 0; } NSMutableArray *results = [NSMutableArray array]; NSString *fullText = _attributedString.string; // Use NSString's rangeOfString for searching NSRange searchRange = NSMakeRange(0, [fullText length]); while (searchRange.location < [fullText length]) { NSRange foundRange = [fullText rangeOfString:searchString options:NSCaseInsensitiveSearch range:searchRange]; if (foundRange.location == NSNotFound) { break; } [results addObject:[NSValue valueWithRange:foundRange]]; // Move search range forward searchRange.location = foundRange.location + foundRange.length; searchRange.length = [fullText length] - searchRange.location; } // Store results _searchResultString = searchString; _searchResultRanges = [results copy]; // Create highlight ranges NSMutableArray *highlights = [NSMutableArray array]; UIColor *highlightColor = [UIColor yellowColor]; for (NSValue *rangeValue in results) { [highlights addObject:@{ @"range": rangeValue, @"color": highlightColor }]; } _highlightRanges = [highlights copy]; return [results count]; } /** * Clears all search result highlights. */ - (void)clearSearchHighlights { _searchResultString = nil; _searchResultRanges = nil; _highlightRanges = nil; } @end