986 lines
29 KiB
Objective-C
986 lines
29 KiB
Objective-C
//
|
|
// 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 <CoreText/CoreText.h>
|
|
|
|
// 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<WRCoreTextLayoutLine *> *_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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *)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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<NSValue *> *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<NSValue *> *)selectedRanges {
|
|
NSMutableArray<NSValue *> *ranges = [NSMutableArray array];
|
|
|
|
// Build ranges from selected line indices
|
|
NSArray<WRCoreTextLayoutLine *> *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<WRCoreTextLayoutLine *> *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<NSValue *> *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
|