Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,985 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user