// // WRCoreTextLayouter.m // WeRead // // Reverse-engineered from binary analysis // CoreText typesetter wrapper implementation // // This file contains pseudo-code reconstruction of the WRCoreTextLayouter // class based on ivar analysis, method signatures, and behavioral context. // #import "WRCoreTextLayouter.h" #import "WRCoreTextLayoutFrame.h" #import "WRMarkContentChapter.h" #import // Forward declarations for internal C types (not in public headers) typedef struct _CTTypesetter *CTTypesetterRef; typedef struct _CTFramesetter *CTFramesetterRef; #pragma mark - Internal Constants // Size patterns for image resizing (from analysis of resizedImageForImagePath:) static NSString * const kSizePatternFull = @"full"; static NSString * const kSizePatternHalf = @"half"; static NSString * const kSizePatternThird = @"third"; static NSString * const kSizePatternQuarter = @"quarter"; // Default layout configuration values static const CGFloat kDefaultFrameWidth = 320.0; static const CGFloat kDefaultFrameHeight = 480.0; static const CGFloat kDefaultColumnGap = 20.0; #pragma mark - WRCoreTextLayoutConfig Implementation @implementation WRCoreTextLayoutConfig - (instancetype)init { self = [super init]; if (self) { _frameWidth = kDefaultFrameWidth; _frameHeight = kDefaultFrameHeight; _edgeInsets = UIEdgeInsetsMake(10, 15, 10, 15); _numberOfColumns = 1; _columnGap = kDefaultColumnGap; _avoidOrphans = YES; _avoidWidows = YES; _hyphenation = YES; } return self; } @end #pragma mark - Private Interface @interface WRCoreTextLayouter () { // CoreText typesetter - the core text processing engine // This is a C object that performs the actual glyph layout CTTypesetterRef _typesetter; // CoreText framesetter - creates frames from the typesetter // Used when frame-based layout is needed (with paths) CTFramesetterRef _framesetter; // Track whether the typesetter needs recreation BOOL _typesetterDirty; // Track whether the framesetter needs recreation BOOL _framesetterDirty; // Internal lock for thread safety NSLock *_layoutLock; // Cache for image resizing operations NSCache *_imageCache; // Current layout configuration WRCoreTextLayoutConfig *_config; } @end #pragma mark - WRCoreTextLayouter Implementation @implementation WRCoreTextLayouter #pragma mark - Lifecycle - (instancetype)init { self = [super init]; if (self) { [self commonInit]; } return self; } - (instancetype)initWithAttributedString:(NSAttributedString *)attributedString { self = [super init]; if (self) { [self commonInit]; _attributedString = [attributedString copy]; _internalAttributedString = [attributedString copy]; _typesetterDirty = YES; } return self; } - (instancetype)initWithAttributedString:(NSAttributedString *)attributedString config:(WRCoreTextLayoutConfig *)config { self = [super init]; if (self) { [self commonInit]; _attributedString = [attributedString copy]; _internalAttributedString = [attributedString copy]; _config = config; _typesetterDirty = YES; } return self; } /** * Common initialization - sets up internal state. * Called by all init methods. */ - (void)commonInit { _layoutLock = [[NSLock alloc] init]; _imageCache = [[NSCache alloc] init]; _imageCache.countLimit = 50; // Cache up to 50 resized images _layoutFrames = [NSMutableArray array]; _typesetterDirty = YES; _framesetterDirty = YES; } - (void)dealloc { // Release CoreText objects - these are C objects, not ObjC if (_typesetter) { CFRelease(_typesetter); _typesetter = NULL; } if (_framesetter) { CFRelease(_framesetter); _framesetter = NULL; } } #pragma mark - Property Accessors - (void)setAttributedString:(NSAttributedString *)attributedString { // When the attributed string changes, mark typesetter as dirty _attributedString = [attributedString copy]; _internalAttributedString = [attributedString copy]; _typesetterDirty = YES; _framesetterDirty = YES; // Clear existing layout frames as they're now invalid [_layoutFrames removeAllObjects]; // Extract plain text for convenience _plainText = [_attributedString string]; } - (void)setPlainText:(NSString *)plainText { _plainText = [plainText copy]; // Note: This doesn't update the attributed string - it's read-only metadata } #pragma mark - Typesetter Management /** * Creates the CTTypesetter from the current attributed string. * * CTTypesetter is the low-level CoreText object that performs glyph layout. * It analyzes the attributed string and prepares it for line-by-line layout. * * Algorithm: * 1. Validate we have an attributed string * 2. Release existing typesetter if any * 3. Create new CTTypesetter with the attributed string * 4. Mark as clean */ - (void)createTypesetter { [_layoutLock lock]; if (!_typesetterDirty && _typesetter) { [_layoutLock unlock]; return; // Already up to date } // Release old typesetter if (_typesetter) { CFRelease(_typesetter); _typesetter = NULL; } // Need attributed string to create typesetter if (!_internalAttributedString) { [_layoutLock unlock]; return; } // Create the CTTypesetter // CTTypesetterCreateWithAttributedString analyzes the string and // prepares internal data structures for efficient line breaking _typesetter = CTTypesetterCreateWithAttributedString( (__bridge CFAttributedStringRef)_internalAttributedString ); _typesetterDirty = NO; [_layoutLock unlock]; } /** * Creates the CTFramesetter from the current attributed string. * * CTFramesetter is a higher-level API that creates CTFrame objects. * It internally manages its own CTTypesetter. */ - (void)createFramesetter { [_layoutLock lock]; if (!_framesetterDirty && _framesetter) { [_layoutLock unlock]; return; } if (_framesetter) { CFRelease(_framesetter); _framesetter = NULL; } if (!_internalAttributedString) { [_layoutLock unlock]; return; } // CTFramesetterCreateWithAttributedString creates a framesetter // that can produce CTFrame objects for arbitrary paths _framesetter = CTFramesetterCreateWithAttributedString( (__bridge CFAttributedStringRef)_internalAttributedString ); _framesetterDirty = NO; [_layoutLock unlock]; } - (void)invalidateTypesetter { [_layoutLock lock]; _typesetterDirty = YES; _framesetterDirty = YES; [_layoutLock unlock]; } #pragma mark - Layout Frame Creation /** * Creates a WRCoreTextLayoutFrame for a given string range. * * This is the primary layout method. It: * 1. Ensures the typesetter is ready * 2. Creates a CTTypesetter layout for the range * 3. Wraps it in a WRCoreTextLayoutFrame * * @param range Range of the attributed string to lay out * @param frame Bounding rectangle for the layout * @return A new WRCoreTextLayoutFrame */ - (WRCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range frame:(CGRect)frame { [self createTypesetter]; if (!_typesetter) { return nil; } [_layoutLock lock]; // Create a CGPath for the frame bounds // The path defines the region where text will be laid out CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, frame); // Create CTFrame from the typesetter // CTTypesetterCreateFrame creates a frame for the given range within the path CTFrameRef ctFrame = CTTypesetterCreateFrame( _typesetter, CFRangeMake(range.location, range.length), path, NULL // No frame attributes ); CGPathRelease(path); if (!ctFrame) { [_layoutLock unlock]; return nil; } // Create our wrapper layout frame WRCoreTextLayoutFrame *layoutFrame = [[WRCoreTextLayoutFrame alloc] init]; layoutFrame.attributedString = _internalAttributedString; // The layout frame takes ownership of the CTFrame [layoutFrame setCTFrame:ctFrame range:range]; CFRelease(ctFrame); [_layoutFrames addObject:layoutFrame]; [_layoutLock unlock]; return layoutFrame; } /** * Creates layout frames for the entire attributed string. * This is the pagination method - it breaks content into pages. * * Algorithm: * 1. Get the total string length * 2. Start from index 0 * 3. For each page: * a. Use CTTypesetterSuggestLineBreak to find how much fits * b. Create a layout frame for that range * c. Advance the cursor * 4. Repeat until all text is laid out * * @param pageSize Size of each page * @return Array of WRCoreTextLayoutFrame objects */ - (NSArray *)layoutFramesForPageSize:(CGSize)pageSize { [self createTypesetter]; if (!_typesetter || !_internalAttributedString) { return @[]; } [_layoutLock lock]; NSMutableArray *frames = [NSMutableArray array]; NSUInteger totalLength = [_internalAttributedString length]; NSUInteger currentIndex = 0; // Calculate the usable width (accounting for margins) UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15); CGFloat usableWidth = pageSize.width - insets.left - insets.right; while (currentIndex < totalLength) { // CTTypesetterSuggestLineBreak suggests how many characters fit in the width // This is the core pagination algorithm CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak( _typesetter, currentIndex, usableWidth ); if (lineBreakIndex <= 0) { // Safety: avoid infinite loop break; } NSRange pageRange = NSMakeRange(currentIndex, lineBreakIndex); CGRect pageRect = CGRectMake(0, 0, pageSize.width, pageSize.height); // Create layout frame for this page WRCoreTextLayoutFrame *frame = [self layoutFrameWithRange:pageRange frame:pageRect]; if (frame) { [frames addObject:frame]; } currentIndex += lineBreakIndex; } [_layoutLock unlock]; return [frames copy]; } - (WRCoreTextLayoutFrame *)layoutFrameForRange:(NSRange)range rect:(CGRect)rect columns:(NSUInteger)columns { if (range.length == 0) { return nil; } // For multi-column layout, we'd need to create a more complex path if (columns > 1) { // Create column-based path CGMutablePathRef path = CGPathCreateMutable(); CGFloat columnWidth = (rect.size.width - (columns - 1) * _config.columnGap) / columns; for (NSUInteger i = 0; i < columns; i++) { CGFloat x = rect.origin.x + i * (columnWidth + _config.columnGap); CGRect columnRect = CGRectMake(x, rect.origin.y, columnWidth, rect.size.height); CGPathAddRect(path, NULL, columnRect); } [self createFramesetter]; if (!_framesetter) { CGPathRelease(path); return nil; } [_layoutLock lock]; CTFrameRef ctFrame = CTFramesetterCreateFrame( _framesetter, CFRangeMake(range.location, range.length), path, NULL ); CGPathRelease(path); if (!ctFrame) { [_layoutLock unlock]; return nil; } WRCoreTextLayoutFrame *layoutFrame = [[WRCoreTextLayoutFrame alloc] init]; layoutFrame.attributedString = _internalAttributedString; [layoutFrame setCTFrame:ctFrame range:range]; CFRelease(ctFrame); [_layoutLock unlock]; return layoutFrame; } // Single column - use the simpler typesetter path return [self layoutFrameWithRange:range frame:rect]; } #pragma mark - Page Background Images /** * Generates a page background image for a given text range. * * This method creates a background image that can include: * - Theme-specific background textures * - Decorative elements (borders, patterns) * - Chapter-specific backgrounds (e.g., for chapter openings) * * The themeBgColor is used to tint the background to match the current theme. * * @param range The text range on the page * @param themeBgColor The theme's background color * @return A UIImage for the page background */ - (UIImage *)pageBackgroundImageAtRange:(NSRange)range themeBgColor:(UIColor *)themeBgColor { // Check cache first NSString *cacheKey = [NSString stringWithFormat:@"bg_%lu_%lu_%@", (unsigned long)range.location, (unsigned long)range.length, themeBgColor]; UIImage *cachedImage = [_imageCache objectForKey:cacheKey]; if (cachedImage) { return cachedImage; } // Get the text in this range to check for special content NSString *pageText = [_internalAttributedString.string substringWithRange:range]; // Determine the background type based on content // Chapter openings might get special treatment BOOL isChapterStart = (range.location == 0); BOOL hasChapterTitle = [pageText containsString:@"第"] || [pageText containsString:@"Chapter"]; CGSize imageSize = _config ? CGSizeMake(_config.frameWidth, _config.frameHeight) : CGSizeMake(320, 480); UIGraphicsBeginImageContextWithOptions(imageSize, YES, [UIScreen mainScreen].scale); CGContextRef context = UIGraphicsGetCurrentContext(); // Fill with theme background color [themeBgColor setFill]; CGContextFillRect(context, CGRectMake(0, 0, imageSize.width, imageSize.height)); if (isChapterStart || hasChapterTitle) { // Special background for chapter start pages // Could include decorative borders, ornamental elements [self drawChapterStartDecorationInContext:context size:imageSize color:themeBgColor]; } else { // Regular page background // Could include subtle patterns, margins, page numbers area [self drawRegularPageDecorationInContext:context size:imageSize color:themeBgColor]; } UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); // Cache the result if (result) { [_imageCache setObject:result forKey:cacheKey]; } return result; } /** * Draws decorative elements for chapter start pages. * This includes ornamental borders and chapter-specific decorations. */ - (void)drawChapterStartDecorationInContext:(CGContextRef)context size:(CGSize)size color:(UIColor *)color { // Draw a decorative border CGRect borderRect = CGRectInset(CGRectMake(0, 0, size.width, size.height), 15, 20); CGContextSetStrokeColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor); CGContextSetLineWidth(context, 1.0); CGContextStrokeRect(context, borderRect); // Could add more ornamental elements: // - Corner decorations // - Header/footer ornaments // - Drop cap indicators } /** * Draws standard page decorations (margins, guides). */ - (void)drawRegularPageDecorationInContext:(CGContextRef)context size:(CGSize)size color:(UIColor *)color { // Draw subtle margin guides CGFloat leftMargin = _config ? _config.edgeInsets.left : 15; CGFloat rightMargin = _config ? _config.edgeInsets.right : 15; CGContextSetStrokeColorWithColor(context, [color colorWithAlphaComponent:0.1].CGColor); CGContextSetLineWidth(context, 0.5); // Left margin line CGContextMoveToPoint(context, leftMargin, 0); CGContextAddLineToPoint(context, leftMargin, size.height); CGContextStrokePath(context); // Right margin line CGContextMoveToPoint(context, size.width - rightMargin, 0); CGContextAddLineToPoint(context, size.width - rightMargin, size.height); CGContextStrokePath(context); } #pragma mark - Image Utilities /** * Resizes an image for display within the text layout. * * This method handles various image sizing scenarios: * - Full-width images * - Half-width images (side by side) * - Thumbnail-sized images * - Dark mode adaptations * * The sizePattern parameter controls how the image is scaled: * - "full": Scale to fill the available width * - "half": Scale to half width * - "third": Scale to one-third width * - "quarter": Scale to one-quarter width * * @param imagePath Path to the image file or URL string * @param rect Target rectangle for positioning * @param position Position index (for multi-image layouts) * @param sizePattern Size pattern string * @param darkMode Whether to apply dark mode adjustments * @param themeBgColor Theme background color for blending * @return Resized UIImage */ - (UIImage *)resizedImageForImagePath:(NSString *)imagePath rect:(CGRect)rect position:(NSUInteger)position sizePattern:(NSString *)sizePattern darkMode:(BOOL)darkMode themeBgColor:(UIColor *)themeBgColor { // Generate cache key NSString *cacheKey = [NSString stringWithFormat:@"img_%@_%@_%lu_%d", imagePath, sizePattern, (unsigned long)position, darkMode]; UIImage *cachedImage = [_imageCache objectForKey:cacheKey]; if (cachedImage) { return cachedImage; } // Load the original image UIImage *originalImage = nil; // Handle different image path formats if ([imagePath hasPrefix:@"http://"] || [imagePath hasPrefix:@"https://"]) { // Remote image - would need async loading // For now, return placeholder originalImage = [UIImage imageNamed:@"placeholder_book_image"]; } else if ([imagePath hasPrefix:@"/"]) { // Absolute file path originalImage = [UIImage imageWithContentsOfFile:imagePath]; } else { // Bundle resource originalImage = [UIImage imageNamed:imagePath]; } if (!originalImage) { // Return a placeholder image return [self placeholderImageForSize:rect.size]; } // Calculate target size based on size pattern CGSize targetSize = [self targetSizeForPattern:sizePattern rect:rect imageSize:originalImage.size]; // Handle dark mode if (darkMode) { originalImage = [self darkModeAdjustedImage:originalImage withBgColor:themeBgColor]; } // Resize the image UIImage *resizedImage = [self resizeImage:originalImage toSize:targetSize]; // Cache the result if (resizedImage) { [_imageCache setObject:resizedImage forKey:cacheKey]; } return resizedImage; } /** * Calculates the target size based on a size pattern string. */ - (CGSize)targetSizeForPattern:(NSString *)pattern rect:(CGRect)rect imageSize:(CGSize)imageSize { CGFloat availableWidth = rect.size.width; if ([pattern isEqualToString:kSizePatternFull]) { // Full width - maintain aspect ratio CGFloat scale = availableWidth / imageSize.width; return CGSizeMake(availableWidth, imageSize.height * scale); } else if ([pattern isEqualToString:kSizePatternHalf]) { // Half width CGFloat halfWidth = availableWidth / 2.0; CGFloat scale = halfWidth / imageSize.width; return CGSizeMake(halfWidth, imageSize.height * scale); } else if ([pattern isEqualToString:kSizePatternThird]) { // One-third width CGFloat thirdWidth = availableWidth / 3.0; CGFloat scale = thirdWidth / imageSize.width; return CGSizeMake(thirdWidth, imageSize.height * scale); } else if ([pattern isEqualToString:kSizePatternQuarter]) { // One-quarter width CGFloat quarterWidth = availableWidth / 4.0; CGFloat scale = quarterWidth / imageSize.width; return CGSizeMake(quarterWidth, imageSize.height * scale); } // Default: fit within the rect while maintaining aspect ratio return [self fitSize:imageSize inSize:rect.size]; } /** * Resizes an image to the target size using high-quality interpolation. */ - (UIImage *)resizeImage:(UIImage *)image toSize:(CGSize)size { UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale); [image drawInRect:CGRectMake(0, 0, size.width, size.height)]; UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return result; } /** * Adjusts an image for dark mode by blending with the background color. */ - (UIImage *)darkModeAdjustedImage:(UIImage *)image withBgColor:(UIColor *)bgColor { CGSize size = image.size; UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale); // Draw background [bgColor setFill]; CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height)); // Draw image with reduced opacity for dark mode blending [image drawInRect:CGRectMake(0, 0, size.width, size.height) blendMode:kCGBlendModeNormal alpha:0.85]; UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return result; } /** * Calculates the size that fits within a container while maintaining aspect ratio. */ - (CGSize)fitSize:(CGSize)imageSize inSize:(CGSize)containerSize { CGFloat widthRatio = containerSize.width / imageSize.width; CGFloat heightRatio = containerSize.height / imageSize.height; CGFloat scale = MIN(widthRatio, heightRatio); return CGSizeMake(imageSize.width * scale, imageSize.height * scale); } /** * Creates a placeholder image for missing images. */ - (UIImage *)placeholderImageForSize:(CGSize)size { UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale); // Light gray background [[UIColor colorWithWhite:0.9 alpha:1.0] setFill]; CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height)); // Draw a simple icon indicator [[UIColor colorWithWhite:0.7 alpha:1.0] setFill]; CGFloat iconSize = MIN(size.width, size.height) * 0.3; CGFloat x = (size.width - iconSize) / 2; CGFloat y = (size.height - iconSize) / 2; CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(x, y, iconSize, iconSize)); UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return result; } #pragma mark - Pagination Helpers /** * Calculates the total number of pages for a given page size. * * Uses CTTypesetterSuggestLineBreak to simulate pagination * without actually creating layout frame objects. */ - (NSUInteger)numberOfPagesForPageSize:(CGSize)pageSize { [self createTypesetter]; if (!_typesetter || !_internalAttributedString) { return 0; } [_layoutLock lock]; NSUInteger pageCount = 0; NSUInteger totalLength = [_internalAttributedString length]; NSUInteger currentIndex = 0; UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15); CGFloat usableWidth = pageSize.width - insets.left - insets.right; while (currentIndex < totalLength) { CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak( _typesetter, currentIndex, usableWidth ); if (lineBreakIndex <= 0) { break; } pageCount++; currentIndex += lineBreakIndex; } [_layoutLock unlock]; return pageCount; } /** * Returns the text range for a specific page index. */ - (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex pageSize:(CGSize)pageSize { [self createTypesetter]; if (!_typesetter || !_internalAttributedString) { return NSMakeRange(0, 0); } [_layoutLock lock]; NSUInteger totalLength = [_internalAttributedString length]; NSUInteger currentIndex = 0; NSUInteger currentPage = 0; UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15); CGFloat usableWidth = pageSize.width - insets.left - insets.right; while (currentIndex < totalLength && currentPage <= pageIndex) { CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak( _typesetter, currentIndex, usableWidth ); if (lineBreakIndex <= 0) { break; } if (currentPage == pageIndex) { [_layoutLock unlock]; return NSMakeRange(currentIndex, lineBreakIndex); } currentIndex += lineBreakIndex; currentPage++; } [_layoutLock unlock]; return NSMakeRange(0, 0); } /** * Returns suggested line fragment heights for precise layout calculations. * * This method analyzes the attributed string and returns the heights * of each line as CoreText would lay them out. This is used for: * - Precise pagination calculations * - Avoiding orphans and widows * - Ensuring consistent line spacing */ - (NSArray *)suggestedLineFragHeights { [self createTypesetter]; if (!_typesetter || !_internalAttributedString) { return @[]; } [_layoutLock lock]; NSMutableArray *heights = [NSMutableArray array]; NSUInteger totalLength = [_internalAttributedString length]; NSUInteger currentIndex = 0; // Use a large width to get all lines in a single column CGFloat width = _config ? _config.frameWidth : 320.0; while (currentIndex < totalLength) { CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak( _typesetter, currentIndex, width ); if (lineBreakIndex <= 0) { break; } // Create a temporary CTLine to measure its height CTLineRef line = CTTypesetterCreateLine( _typesetter, CFRangeMake(currentIndex, lineBreakIndex) ); if (line) { CGFloat ascent, descent, leading; CTLineGetTypographicBounds(line, &ascent, &descent, &leading); CGFloat lineHeight = ascent + descent + leading; [heights addObject:@(lineHeight)]; CFRelease(line); } currentIndex += lineBreakIndex; } [_layoutLock unlock]; return [heights copy]; } @end