887 lines
27 KiB
Objective-C
887 lines
27 KiB
Objective-C
//
|
|
// 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 <CoreText/CoreText.h>
|
|
|
|
// 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<WRCoreTextLayoutFrame *> *)layoutFramesForPageSize:(CGSize)pageSize {
|
|
[self createTypesetter];
|
|
|
|
if (!_typesetter || !_internalAttributedString) {
|
|
return @[];
|
|
}
|
|
|
|
[_layoutLock lock];
|
|
|
|
NSMutableArray<WRCoreTextLayoutFrame *> *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<NSNumber *> *)suggestedLineFragHeights {
|
|
[self createTypesetter];
|
|
|
|
if (!_typesetter || !_internalAttributedString) {
|
|
return @[];
|
|
}
|
|
|
|
[_layoutLock lock];
|
|
|
|
NSMutableArray<NSNumber *> *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
|