Epub阅读器0.0.1

This commit is contained in:
shen
2026-05-21 19:40:51 +08:00
commit daa36d8fe7
559 changed files with 106266 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
//
// DTCoreTextGlyphRun.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// This class represents a glyph run within a line of text.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextLayoutLine;
@class DTTextAttachment;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextGlyphRun - Represents a glyph run within a line.
*
* A glyph run is a sequence of glyphs that share the same attributes
* (font, color, etc.). This class wraps CTRun and provides high-level
* access to individual glyphs and their properties.
*
* Key features:
* - Wraps CTRun for high-level access
* - Contains glyph images and paths
* - Handles text attachments (images, etc.)
* - Provides hit testing within the run
* - Supports custom drawing and effects
*/
@interface DTCoreTextGlyphRun : NSObject
#pragma mark - Properties
/** The CTRun reference */
@property (nonatomic, assign, readonly) CTRunRef ctRun;
/** The frame (position and size) of this run */
@property (nonatomic, assign) CGRect frame;
/** The range of characters in this run */
@property (nonatomic, assign, readonly) NSRange stringRange;
/** The index of this run in its parent line */
@property (nonatomic, assign, readonly) NSUInteger runIndex;
/** The attributes shared by all glyphs in this run */
@property (nonatomic, strong, readonly) NSDictionary *attributes;
/** The parent layout line */
@property (nonatomic, weak, nullable) DTCoreTextLayoutLine *layoutLine;
/** Array of glyph images (for custom rendering) */
@property (nonatomic, strong, nullable) NSArray *glyphImages;
/** Array of attachment objects */
@property (nonatomic, strong, nullable) NSArray<DTTextAttachment *> *attachments;
/** The text attachment (if this run contains one) */
@property (nonatomic, strong, nullable) DTTextAttachment *attachment;
/** Whether this run is a placeholder for an attachment */
@property (nonatomic, assign, readonly) BOOL isAttachment;
/** Whether this run is a whitespace */
@property (nonatomic, assign, readonly) BOOL isWhitespace;
/** Whether this run is a newline */
@property (nonatomic, assign, readonly) BOOL isNewline;
/** The font used in this run */
@property (nonatomic, strong, nullable) UIFont *font;
/** The text color */
@property (nonatomic, strong, nullable) UIColor *textColor;
/** The background color */
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
/** The strikethrough color */
@property (nonatomic, strong, nullable) UIColor *strikethroughColor;
/** Whether strikethrough is enabled */
@property (nonatomic, assign) BOOL hasStrikethrough;
/** Whether underline is enabled */
@property (nonatomic, assign) BOOL hasUnderline;
/** The underline style */
@property (nonatomic, assign) NSUnderlineStyle underlineStyle;
/** The underline color */
@property (nonatomic, strong, nullable) UIColor *underlineColor;
/** Number of glyphs in this run */
@property (nonatomic, assign, readonly) NSUInteger numberOfGlyphs;
/** Array of glyph values */
@property (nonatomic, strong, readonly) NSArray<NSNumber *> *glyphs;
/** Array of glyph positions */
@property (nonatomic, strong, readonly) NSArray<NSValue *> *glyphPositions;
/** Array of glyph advances */
@property (nonatomic, strong, readonly) NSArray<NSNumber *> *glyphAdvances;
/** The writing direction */
@property (nonatomic, assign) CTWritingDirection writingDirection;
/** Whether this run is right-to-left */
@property (nonatomic, assign, readonly) BOOL isRTL;
#pragma mark - Initialization
/**
* Initialize with a CTRun.
*
* @param ctRun The CoreText run
* @param frame The frame of the run
* @param range The string range
* @param attributes The run attributes
* @param index The run index
* @return Initialized glyph run
*/
- (instancetype)initWithCTRun:(CTRunRef)ctRun
frame:(CGRect)frame
range:(NSRange)range
attributes:(NSDictionary *)attributes
index:(NSUInteger)index;
#pragma mark - Glyph Access
/**
* Returns the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph value
*/
- (CGGlyph)glyphAtIndex:(NSUInteger)index;
/**
* Returns the position of the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph position
*/
- (CGPoint)positionForGlyphAtIndex:(NSUInteger)index;
/**
* Returns the advance of the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph advance
*/
- (CGFloat)advanceForGlyphAtIndex:(NSUInteger)index;
/**
* Returns the rect for the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph rect
*/
- (CGRect)rectForGlyphAtIndex:(NSUInteger)index;
#pragma mark - Path Operations
/**
* Creates a CGPath containing all glyphs in this run.
*
* This method creates a path that outlines all the glyphs.
* It's used for custom drawing, hit testing, and effects.
*
* @return A CGPath containing the glyph outlines, or NULL
*/
- (nullable CGPathRef)newPathWithGlyphs;
/**
* Creates a CGPath for a specific glyph.
*
* @param index The glyph index
* @return A CGPath for the glyph, or NULL
*/
- (nullable CGPathRef)newPathForGlyphAtIndex:(NSUInteger)index;
/**
* Creates a bounding path for all glyphs.
*
* @return A CGPath bounding all glyphs
*/
- (nullable CGPathRef)newBoundingPath;
#pragma mark - Image Operations
/**
* Returns the image for the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph image, or nil
*/
- (nullable UIImage *)imageForGlyphAtIndex:(NSUInteger)index;
/**
* Returns the bounding rect for the glyph image.
*
* @param index The glyph index
* @return The image rect
*/
- (CGRect)imageRectForGlyphAtIndex:(NSUInteger)index;
#pragma mark - Hit Testing
/**
* Returns the glyph index at the given point.
*
* @param point The point to test
* @return The glyph index, or NSNotFound
*/
- (NSUInteger)glyphIndexAtPoint:(CGPoint)point;
/**
* Returns the string index at the given point.
*
* @param point The point to test
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
/**
* Returns the rect for a given string index.
*
* @param index The string index
* @return The rect for the character
*/
- (CGRect)rectForStringIndex:(NSUInteger)index;
#pragma mark - Drawing
/**
* Draws this run into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context;
/**
* Draws this run with a specific color.
*
* @param context The CGContext to draw into
* @param color The text color
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color;
/**
* Draws the attachment (if any) into the context.
*
* @param context The CGContext to draw into
*/
- (void)drawAttachmentInContext:(CGContextRef)context;
#pragma mark - Run Comparison
/**
* Compares this run to another run for ordering.
*/
- (NSComparisonResult)compareToRun:(DTCoreTextGlyphRun *)otherRun;
/**
* Returns whether this run contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index;
/**
* Returns whether this run intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range;
#pragma mark - Metrics
/**
* Returns the typographic bounds of this run.
*
* @param ascent Output parameter for ascent
* @param descent Output parameter for descent
* @param leading Output parameter for leading
* @return The width of the run
*/
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading;
/**
* Returns the bounds of the run (bounding box).
*/
- (CGRect)bounds;
/**
* Returns the width of the run.
*/
- (CGFloat)width;
/**
* Returns the height of the run.
*/
- (CGFloat)height;
@end
NS_ASSUME_NONNULL_END
+698
View File
@@ -0,0 +1,698 @@
//
// DTCoreTextGlyphRun.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextGlyphRun
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextGlyphRun.h"
#import "DTCoreTextLayoutLine.h"
#import <CoreText/CoreText.h>
#pragma mark - DTTextAttachment Stub
/**
* Stub for DTTextAttachment class.
* Represents an embedded object (image, view, etc.) in the text.
*/
@interface DTTextAttachment : NSObject
@property (nonatomic, strong) UIImage *image;
@property (nonatomic, assign) CGSize displaySize;
@property (nonatomic, assign) CGRect frame;
@property (nonatomic, copy) NSString *contentType;
@end
@implementation DTTextAttachment
@end
#pragma mark - Private Interface
@interface DTCoreTextGlyphRun () {
// Cached glyph data
CGGlyph *_glyphs;
CGPoint *_positions;
CGFloat *_advances;
CFIndex _glyphCount;
// Whether glyph data has been extracted
BOOL _glyphsExtracted;
// Cached metrics
CGFloat _cachedAscent;
CGFloat _cachedDescent;
CGFloat _cachedLeading;
CGFloat _cachedWidth;
BOOL _metricsCached;
// Internal lock
NSLock *_lock;
}
@end
#pragma mark - DTCoreTextGlyphRun Implementation
@implementation DTCoreTextGlyphRun
#pragma mark - Lifecycle
- (instancetype)initWithCTRun:(CTRunRef)ctRun
frame:(CGRect)frame
range:(NSRange)range
attributes:(NSDictionary *)attributes
index:(NSUInteger)index {
self = [super init];
if (self) {
_lock = [[NSLock alloc] init];
// Store the CTRun with a retain
if (ctRun) {
_ctRun = (CTRunRef)CFRetain(ctRun);
}
_frame = frame;
_stringRange = range;
_attributes = [attributes copy];
_runIndex = index;
// Initialize state
_glyphsExtracted = NO;
_metricsCached = NO;
_glyphs = NULL;
_positions = NULL;
_advances = NULL;
_glyphCount = 0;
// Extract common attributes
[self extractAttributes];
}
return self;
}
- (void)dealloc {
if (_ctRun) {
CFRelease(_ctRun);
_ctRun = NULL;
}
// Free allocated glyph data
if (_glyphs) {
free(_glyphs);
_glyphs = NULL;
}
if (_positions) {
free(_positions);
_positions = NULL;
}
if (_advances) {
free(_advances);
_advances = NULL;
}
}
#pragma mark - Attribute Extraction
/**
* Extracts common attributes from the attributes dictionary.
*/
- (void)extractAttributes {
if (!_attributes) {
return;
}
// Extract font
CTFontRef ctFont = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
if (ctFont) {
_font = [UIFont fontWithDescriptor:[UIFontDescriptor fontDescriptorWithCTFont:ctFont]
size:CTFontGetSize(ctFont)];
}
// Extract text color
CGColorRef textColor = (__bridge CGColorRef)_attributes[(__bridge NSString *)kCTForegroundColorAttributeName];
if (textColor) {
_textColor = [UIColor colorWithCGColor:textColor];
}
// Extract background color
CGColorRef bgColor = (__bridge CGColorRef)_attributes[@"DTBackgroundColor"];
if (bgColor) {
_backgroundColor = [UIColor colorWithCGColor:bgColor];
}
// Extract strikethrough
NSNumber *strikethrough = _attributes[(__bridge NSString *)kCTSuperscriptAttributeName];
if (strikethrough) {
_hasStrikethrough = [strikethrough boolValue];
}
// Extract underline
NSNumber *underlineStyle = _attributes[(__bridge NSString *)kCTUnderlineColorAttributeName];
if (underlineStyle) {
_hasUnderline = YES;
_underlineStyle = [underlineStyle integerValue];
}
// Check for attachment
_attachment = _attributes[@"DTTextAttachment"];
if (_attachment) {
_isAttachment = YES;
}
// Extract writing direction
NSArray *writingDirection = _attributes[(__bridge NSString *)kCTWritingDirectionAttributeName];
if ([writingDirection count] > 0) {
_writingDirection = [writingDirection[0] integerValue];
}
}
#pragma mark - Glyph Extraction
/**
* Extracts glyph data from the CTRun.
*
* This method extracts:
* - Glyph values (CGGlyph)
* - Glyph positions (CGPoint)
* - Glyph advances (CGFloat)
*/
- (void)extractGlyphs {
if (_glyphsExtracted || !_ctRun) {
return;
}
[_lock lock];
// Get the number of glyphs
_glyphCount = CTRunGetGlyphCount(_ctRun);
if (_glyphCount == 0) {
_glyphsExtracted = YES;
[_lock unlock];
return;
}
// Allocate memory for glyph data
_glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * _glyphCount);
_positions = (CGPoint *)malloc(sizeof(CGPoint) * _glyphCount);
_advances = (CGFloat *)malloc(sizeof(CGFloat) * _glyphCount);
// Extract glyph values
CTRunGetGlyphs(_ctRun, CFRangeMake(0, 0), _glyphs);
// Extract glyph positions
CTRunGetPositions(_ctRun, CFRangeMake(0, 0), _positions);
// Extract glyph advances
CTRunGetAdvances(_ctRun, CFRangeMake(0, 0), (CGSize *)_advances);
_glyphsExtracted = YES;
[_lock unlock];
}
#pragma mark - Glyph Access
- (CGGlyph)glyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount || !_glyphs) {
return 0;
}
return _glyphs[index];
}
- (CGPoint)positionForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount || !_positions) {
return CGPointZero;
}
return _positions[index];
}
- (CGFloat)advanceForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount || !_advances) {
return 0;
}
return _advances[index];
}
- (CGRect)rectForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount) {
return CGRectNull;
}
CGPoint position = _positions[index];
CGFloat advance = _advances[index];
// Calculate the rect for this glyph
// The position is relative to the run's origin
return CGRectMake(
_frame.origin.x + position.x,
_frame.origin.y,
advance,
_frame.size.height
);
}
#pragma mark - Path Operations
/**
* Creates a CGPath containing all glyphs in this run.
*
* This method creates a path by transforming each glyph's outline
* to its position within the run. This is used for:
* - Custom drawing with effects
* - Hit testing with complex shapes
* - Creating outlines for selection
*
* Algorithm:
* 1. Extract all glyph data
* 2. For each glyph:
* a. Get the glyph's path from the font
* b. Transform to the glyph's position
* c. Add to the combined path
* 3. Return the combined path
*
* @return A CGPath containing all glyph outlines
*/
- (CGPathRef)newPathWithGlyphs {
[self extractGlyphs];
if (!_ctRun || _glyphCount == 0) {
return NULL;
}
// Create a mutable path for the combined glyphs
CGMutablePathRef combinedPath = CGPathCreateMutable();
// Get the font from attributes
CTFontRef font = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
if (!font) {
return combinedPath;
}
// Process each glyph
for (CFIndex i = 0; i < _glyphCount; i++) {
CGGlyph glyph = _glyphs[i];
CGPoint position = _positions[i];
// Get the path for this glyph from the font
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, NULL);
if (glyphPath) {
// Create a transform to position the glyph
// The position is relative to the run's origin
CGAffineTransform transform = CGAffineTransformMakeTranslation(
_frame.origin.x + position.x,
_frame.origin.y + position.y
);
// Add the transformed glyph path to the combined path
CGPathAddPath(combinedPath, &transform, glyphPath);
// Release the individual glyph path
CGPathRelease(glyphPath);
}
}
return combinedPath;
}
/**
* Creates a CGPath for a specific glyph.
*/
- (CGPathRef)newPathForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (!_ctRun || index >= _glyphCount) {
return NULL;
}
// Get the font from attributes
CTFontRef font = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
if (!font) {
return NULL;
}
CGGlyph glyph = _glyphs[index];
CGPoint position = _positions[index];
// Get the path for this glyph
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, NULL);
if (!glyphPath) {
return NULL;
}
// Create a transform to position the glyph
CGAffineTransform transform = CGAffineTransformMakeTranslation(
_frame.origin.x + position.x,
_frame.origin.y + position.y
);
// Create a new path with the transform applied
CGMutablePathRef transformedPath = CGPathCreateMutable();
CGPathAddPath(transformedPath, &transform, glyphPath);
// Release the original glyph path
CGPathRelease(glyphPath);
return transformedPath;
}
/**
* Creates a bounding path for all glyphs.
*/
- (CGPathRef)newBoundingPath {
[self extractGlyphs];
if (_glyphCount == 0) {
return NULL;
}
// Create a simple rectangular path that bounds all glyphs
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, _frame);
return path;
}
#pragma mark - Image Operations
/**
* Returns the image for the glyph at the specified index.
*
* This is used for custom rendering where glyphs are replaced with images.
*/
- (UIImage *)imageForGlyphAtIndex:(NSUInteger)index {
if (!_glyphImages || index >= [_glyphImages count]) {
return nil;
}
return _glyphImages[index];
}
/**
* Returns the bounding rect for the glyph image.
*/
- (CGRect)imageRectForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount) {
return CGRectNull;
}
CGPoint position = _positions[index];
CGFloat advance = _advances[index];
// The image rect is centered on the glyph position
return CGRectMake(
_frame.origin.x + position.x,
_frame.origin.y,
advance,
_frame.size.height
);
}
#pragma mark - Hit Testing
/**
* Returns the glyph index at the given point.
*
* This method checks if the point falls within any glyph's bounding rect.
*
* @param point The point to test
* @return The glyph index, or NSNotFound
*/
- (NSUInteger)glyphIndexAtPoint:(CGPoint)point {
[self extractGlyphs];
for (NSUInteger i = 0; i < _glyphCount; i++) {
CGRect glyphRect = [self rectForGlyphAtIndex:i];
if (CGRectContainsPoint(glyphRect, point)) {
return i;
}
}
return NSNotFound;
}
/**
* Returns the string index at the given point.
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
NSUInteger glyphIndex = [self glyphIndexAtPoint:point];
if (glyphIndex == NSNotFound) {
return NSNotFound;
}
// Convert glyph index to string index
// This assumes a 1:1 mapping between glyphs and characters
// For complex scripts, this may need more sophisticated mapping
return _stringRange.location + glyphIndex;
}
/**
* Returns the rect for a given string index.
*/
- (CGRect)rectForStringIndex:(NSUInteger)index {
if (index < _stringRange.location ||
index >= _stringRange.location + _stringRange.length) {
return CGRectNull;
}
// Convert string index to glyph index
NSUInteger glyphIndex = index - _stringRange.location;
return [self rectForGlyphAtIndex:glyphIndex];
}
#pragma mark - Drawing
/**
* Draws this run into a CGContext.
*
* This method draws the glyphs using CTLineDraw or by manually
* drawing each glyph at its position.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context {
if (!context || !_ctRun) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Draw the run using CoreText
CTRunDraw(_ctRun, context, CFRangeMake(0, 0));
// Restore graphics state
CGContextRestoreGState(context);
}
/**
* Draws this run with a specific color.
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color {
if (!context || !_ctRun || !color) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Set the text color
CGContextSetFillColorWithColor(context, color.CGColor);
// Draw the run
CTRunDraw(_ctRun, context, CFRangeMake(0, 0));
// Restore graphics state
CGContextRestoreGState(context);
}
/**
* Draws the attachment (if any) into the context.
*/
- (void)drawAttachmentInContext:(CGContextRef)context {
if (!context || !_attachment || !_attachment.image) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Draw the attachment image at the run's frame
CGContextDrawImage(context, _frame, _attachment.image.CGImage);
// Restore graphics state
CGContextRestoreGState(context);
}
#pragma mark - Run Comparison
/**
* Compares this run to another run for ordering.
*/
- (NSComparisonResult)compareToRun:(DTCoreTextGlyphRun *)otherRun {
// Compare by string range location
if (_stringRange.location < otherRun.stringRange.location) {
return NSOrderedAscending;
} else if (_stringRange.location > otherRun.stringRange.location) {
return NSOrderedDescending;
}
// If same location, compare by run index
if (_runIndex < otherRun.runIndex) {
return NSOrderedAscending;
} else if (_runIndex > otherRun.runIndex) {
return NSOrderedDescending;
}
return NSOrderedSame;
}
/**
* Returns whether this run contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index {
return index >= _stringRange.location &&
index < _stringRange.location + _stringRange.length;
}
/**
* Returns whether this run intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range {
NSRange intersection = NSIntersectionRange(_stringRange, range);
return intersection.length > 0;
}
#pragma mark - Metrics
- (void)calculateMetrics {
if (_metricsCached || !_ctRun) {
return;
}
[_lock lock];
// Get typographic bounds from CTRun
_cachedWidth = CTRunGetTypographicBounds(_ctRun, CFRangeMake(0, 0),
&_cachedAscent, &_cachedDescent, &_cachedLeading);
_metricsCached = YES;
[_lock unlock];
}
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading {
[self calculateMetrics];
if (ascent) *ascent = _cachedAscent;
if (descent) *descent = _cachedDescent;
if (leading) *leading = _cachedLeading;
return _cachedWidth;
}
- (CGRect)bounds {
[self calculateMetrics];
return CGRectMake(0, -_cachedDescent, _cachedWidth, _cachedAscent + _cachedDescent);
}
- (CGFloat)width {
[self calculateMetrics];
return _cachedWidth;
}
- (CGFloat)height {
[self calculateMetrics];
return _cachedAscent + _cachedDescent;
}
- (NSUInteger)numberOfGlyphs {
[self extractGlyphs];
return _glyphCount;
}
- (NSArray<NSNumber *> *)glyphs {
[self extractGlyphs];
NSMutableArray *glyphArray = [NSMutableArray arrayWithCapacity:_glyphCount];
for (CFIndex i = 0; i < _glyphCount; i++) {
[glyphArray addObject:@(_glyphs[i])];
}
return [glyphArray copy];
}
- (NSArray<NSValue *> *)glyphPositions {
[self extractGlyphs];
NSMutableArray *positionArray = [NSMutableArray arrayWithCapacity:_glyphCount];
for (CFIndex i = 0; i < _glyphCount; i++) {
[positionArray addObject:[NSValue valueWithCGPoint:_positions[i]]];
}
return [positionArray copy];
}
- (NSArray<NSNumber *> *)glyphAdvances {
[self extractGlyphs];
NSMutableArray *advanceArray = [NSMutableArray arrayWithCapacity:_glyphCount];
for (CFIndex i = 0; i < _glyphCount; i++) {
[advanceArray addObject:@(_advances[i])];
}
return [advanceArray copy];
}
- (BOOL)isRTL {
return _writingDirection == kCTWritingDirectionRightToLeft;
}
- (BOOL)isWhitespace {
if (!_ctRun || _glyphCount == 0) {
return NO;
}
// Check if all characters in the range are whitespace
// This would need access to the full attributed string
// For now, return NO as a conservative default
return NO;
}
- (BOOL)isNewline {
if (!_ctRun || _glyphCount == 0) {
return NO;
}
// Check if the run contains only newline characters
// This would need access to the full attributed string
return NO;
}
@end
@@ -0,0 +1,239 @@
//
// DTCoreTextLayoutFrame.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// This class represents a single frame of laid-out text.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextLayoutLine;
@class DTCoreTextGlyphRun;
@class NSAttributedString;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextLayoutFrame - Represents a single frame of laid-out text.
*
* This class wraps CTFrame and provides high-level access to the
* layout of text. It contains an array of DTCoreTextLayoutLine objects
* and provides methods for drawing, hit testing, and accessing layout information.
*
* Key features:
* - Wraps CTFrame for high-level access
* - Contains array of DTCoreTextLayoutLine objects
* - Supports drawing to CGContext
* - Provides hit testing for text selection
* - Handles complex text layout with multiple columns
*/
@interface DTCoreTextLayoutFrame : NSObject
#pragma mark - Properties
/** The attributed string that was laid out */
@property (nonatomic, strong, readonly) NSAttributedString *attributedString;
/** The range of the attributed string represented by this frame */
@property (nonatomic, assign, readonly) NSRange range;
/** The bounding rectangle for this frame */
@property (nonatomic, assign, readonly) CGRect frame;
/** The CTFrame reference */
@property (nonatomic, assign, readonly) CTFrameRef ctFrame;
/** Array of DTCoreTextLayoutLine objects */
@property (nonatomic, strong, readonly) NSArray<DTCoreTextLayoutLine *> *lines;
/** Number of lines in this frame */
@property (nonatomic, assign, readonly) NSUInteger numberOfLines;
/** The rendered content height */
@property (nonatomic, assign, readonly) CGFloat contentHeight;
/** The maximum Y coordinate of the content */
@property (nonatomic, assign, readonly) CGFloat maximumY;
/** The minimum Y coordinate of the content */
@property (nonatomic, assign, readonly) CGFloat minimumY;
/** Array of glyph runs in this frame */
@property (nonatomic, strong, readonly) NSArray<DTCoreTextGlyphRun *> *glyphRuns;
/** Whether the frame needs layout */
@property (nonatomic, assign) BOOL needsLayout;
/** The layout size used for this frame */
@property (nonatomic, assign) CGSize layoutSize;
/** The string index where layout ended */
@property (nonatomic, assign, readonly) NSUInteger stringIndex;
/** The visible string range (after truncation) */
@property (nonatomic, assign, readonly) NSRange visibleStringRange;
#pragma mark - Initialization
/**
* Initialize with attributed string, range, and CTFrame.
*
* @param attributedString The attributed string
* @param range The string range
* @param ctFrame The CoreText frame
* @return Initialized layout frame
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
range:(NSRange)range
ctFrame:(CTFrameRef)ctFrame;
#pragma mark - Line Access
/**
* Returns the array of layout lines.
*
* @return Array of DTCoreTextLayoutLine objects
*/
- (NSArray<DTCoreTextLayoutLine *> *)lines;
/**
* Returns the line at the specified index.
*
* @param index The line index
* @return The layout line at the index, or nil
*/
- (nullable DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index;
/**
* Returns the line that contains the given string index.
*
* @param index The string index
* @return The layout line containing the index, or nil
*/
- (nullable DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index;
/**
* Returns the index of the line containing the given point.
*
* @param point The point to test
* @return The line index, or NSNotFound
*/
- (NSUInteger)lineIndexContainingPoint:(CGPoint)point;
#pragma mark - Drawing
/**
* Draw the layout frame content into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context;
/**
* Draw the layout frame with a specific options dictionary.
*
* @param context The CGContext to draw into
* @param options Drawing options
*/
- (void)drawInContext:(CGContextRef)context options:(nullable NSDictionary *)options;
#pragma mark - Hit Testing
/**
* Returns the string index at the given point.
*
* @param point The point to test
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
/**
* Returns the rect for the given string index.
*
* @param index The string index
* @return The rect for the character
*/
- (CGRect)rectForStringIndex:(NSUInteger)index;
/**
* Returns the cursor position for a given string index.
*
* @param index The string index
* @return The cursor rect
*/
- (CGRect)cursorRectForIndex:(NSUInteger)index;
#pragma mark - Text Geometry
/**
* Returns the baseline origin for a given string index.
*
* @param index The string index
* @return The baseline origin point
*/
- (CGPoint)baselineOriginForStringIndex:(NSUInteger)index;
/**
* Returns the frame for a given string range.
*
* @param range The string range
* @return The bounding rect for the range
*/
- (CGRect)frameForStringRange:(NSRange)range;
/**
* Returns the string range for a given rect.
*
* @param rect The rect to test
* @return The string range within the rect
*/
- (NSRange)stringRangeForRect:(CGRect)rect;
#pragma mark - Line Geometry
/**
* Returns the line origins.
*
* @return Array of CGPoint values wrapped in NSValue
*/
- (NSArray<NSValue *> *)lineOrigins;
/**
* Returns the line frames.
*
* @return Array of CGRect values wrapped in NSValue
*/
- (NSArray<NSValue *> *)lineFrames;
#pragma mark - Truncation
/**
* Returns whether the frame is truncated.
*/
- (BOOL)isTruncated;
/**
* Returns the truncation string range.
*/
- (NSRange)truncatedStringRange;
#pragma mark - Layout Updates
/**
* Invalidates the layout, forcing recalculation on next access.
*/
- (void)invalidateLayout;
/**
* Forces a layout pass.
*/
- (void)layoutIfNeeded;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,777 @@
//
// DTCoreTextLayoutFrame.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextLayoutFrame
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextLayoutFrame.h"
#import "DTCoreTextLayoutLine.h"
#import "DTCoreTextGlyphRun.h"
#import <CoreText/CoreText.h>
#pragma mark - Private Interface
@interface DTCoreTextLayoutFrame () {
// The CoreText frame object
CTFrameRef _ctFrame;
// Cached layout lines
NSArray<DTCoreTextLayoutLine *> *_cachedLines;
// Cached glyph runs
NSArray<DTCoreTextGlyphRun *> *_cachedGlyphRuns;
// Cached line origins
NSArray<NSValue *> *_cachedLineOrigins;
// Whether lines have been extracted
BOOL _linesExtracted;
// Whether glyph runs have been extracted
BOOL _glyphRunsExtracted;
// Internal lock
NSLock *_lock;
// Cached content height
CGFloat _cachedContentHeight;
BOOL _contentHeightCached;
// Visible string range
NSRange _visibleStringRange;
// Whether the frame is truncated
BOOL _isTruncated;
// Truncation range
NSRange _truncatedRange;
}
@end
#pragma mark - DTCoreTextLayoutFrame Implementation
@implementation DTCoreTextLayoutFrame
#pragma mark - Lifecycle
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
range:(NSRange)range
ctFrame:(CTFrameRef)ctFrame {
self = [super init];
if (self) {
_attributedString = attributedString;
_range = range;
_lock = [[NSLock alloc] init];
// Store the CTFrame
if (ctFrame) {
_ctFrame = (CTFrameRef)CFRetain(ctFrame);
}
// Initialize state
_linesExtracted = NO;
_glyphRunsExtracted = NO;
_contentHeightCached = NO;
_needsLayout = YES;
// Get the frame path bounds
if (_ctFrame) {
CGPathRef path = CTFrameGetPath(_ctFrame);
_frame = CGPathGetPathBoundingBox(path);
}
// Initialize visible range to full range
_visibleStringRange = range;
}
return self;
}
- (void)dealloc {
if (_ctFrame) {
CFRelease(_ctFrame);
_ctFrame = NULL;
}
}
#pragma mark - Line Extraction
/**
* Extracts layout lines from the CTFrame.
*
* This method iterates through the CTFrame's lines and creates
* DTCoreTextLayoutLine 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 DTCoreTextLayoutLine wrapper
* 3. Cache the results
*/
- (void)extractLines {
if (_linesExtracted || !_ctFrame) {
return;
}
[_lock lock];
// Get the lines from the CTFrame
CFArrayRef ctLines = CTFrameGetLines(_ctFrame);
if (!ctLines) {
_cachedLines = @[];
_linesExtracted = YES;
[_lock unlock];
return;
}
CFIndex lineCount = CFArrayGetCount(ctLines);
if (lineCount == 0) {
_cachedLines = @[];
_linesExtracted = YES;
[_lock unlock];
return;
}
// Get line origins
CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount);
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
NSMutableArray<DTCoreTextLayoutLine *> *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
DTCoreTextLayoutLine *layoutLine = [[DTCoreTextLayoutLine alloc]
initWithCTLine:ctLine
origin:origins[i]
range:range
index:i];
[lines addObject:layoutLine];
}
free(origins);
_cachedLines = [lines copy];
_cachedLineOrigins = nil; // Invalidate origin cache
_linesExtracted = YES;
[_lock unlock];
}
/**
* Returns the array of layout lines.
* Triggers extraction if not already done.
*/
- (NSArray<DTCoreTextLayoutLine *> *)lines {
[self extractLines];
return _cachedLines;
}
- (NSUInteger)numberOfLines {
return [[self lines] count];
}
- (DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index {
NSArray *lines = [self lines];
if (index < [lines count]) {
return lines[index];
}
return nil;
}
/**
* Returns the line that contains the given string index.
*/
- (DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
return line;
}
}
return nil;
}
/**
* Returns the index of the line containing the given point.
*/
- (NSUInteger)lineIndexContainingPoint:(CGPoint)point {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (NSUInteger i = 0; i < [allLines count]; i++) {
DTCoreTextLayoutLine *line = allLines[i];
// Check if point is within the 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) {
return i;
}
}
return NSNotFound;
}
#pragma mark - Glyph Run Extraction
/**
* Extracts glyph runs from all lines.
*
* This method iterates through all lines and extracts their glyph runs,
* creating a flat array of all glyph runs in the frame.
*/
- (void)extractGlyphRuns {
if (_glyphRunsExtracted) {
return;
}
[_lock lock];
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSMutableArray<DTCoreTextGlyphRun *> *allRuns = [NSMutableArray array];
for (DTCoreTextLayoutLine *line in allLines) {
NSArray<DTCoreTextGlyphRun *> *lineRuns = [line glyphRuns];
[allRuns addObjectsFromArray:lineRuns];
}
_cachedGlyphRuns = [allRuns copy];
_glyphRunsExtracted = YES;
[_lock unlock];
}
/**
* Returns all glyph runs in the frame.
*/
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns {
[self extractGlyphRuns];
return _cachedGlyphRuns;
}
#pragma mark - Drawing
/**
* Draws the layout frame content into a CGContext.
*
* This method draws the text content using CTFrameDraw.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context {
if (!context || !_ctFrame) {
return;
}
[_lock lock];
// Save graphics state
CGContextSaveGState(context);
// CoreText uses bottom-left origin, UIKit uses top-left
// We need to flip the coordinate system
CGContextTranslateCTM(context, 0, _frame.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Draw the frame
CTFrameDraw(_ctFrame, context);
// Restore graphics state
CGContextRestoreGState(context);
[_lock unlock];
}
/**
* Draws the layout frame with options.
*
* @param context The CGContext to draw into
* @param options Drawing options dictionary
*/
- (void)drawInContext:(CGContextRef)context options:(NSDictionary *)options {
if (!context || !_ctFrame) {
return;
}
[_lock lock];
// Save graphics state
CGContextSaveGState(context);
// Apply coordinate transformation
CGContextTranslateCTM(context, 0, _frame.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Check for custom drawing options
BOOL drawImages = [options[@"drawImages"] boolValue];
BOOL drawLinks = [options[@"drawLinks"] boolValue];
BOOL drawSelection = [options[@"drawSelection"] boolValue];
// Draw the frame
CTFrameDraw(_ctFrame, context);
// Draw images if requested
if (drawImages) {
[self drawImagesInContext:context options:options];
}
// Draw links if requested
if (drawLinks) {
[self drawLinksInContext:context options:options];
}
// Draw selection if requested
if (drawSelection) {
[self drawSelectionInContext:context options:options];
}
// Restore graphics state
CGContextRestoreGState(context);
[_lock unlock];
}
/**
* Draws images in the layout frame.
*/
- (void)drawImagesInContext:(CGContextRef)context options:(NSDictionary *)options {
// Extract image attachments from attributed string
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSArray<DTCoreTextGlyphRun *> *runs = [line glyphRuns];
for (DTCoreTextGlyphRun *run in runs) {
NSDictionary *attributes = run.attributes;
// Check for attachment
NSTextAttachment *attachment = attributes[@"NSAttachment"];
if (attachment && attachment.image) {
CGRect runFrame = run.frame;
// Draw the image at the run's position
CGContextDrawImage(context, runFrame, attachment.image.CGImage);
}
}
}
}
/**
* Draws link indicators.
*/
- (void)drawLinksInContext:(CGContextRef)context options:(NSDictionary *)options {
// Draw underlines for links
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSArray<DTCoreTextGlyphRun *> *runs = [line glyphRuns];
for (DTCoreTextGlyphRun *run in runs) {
NSDictionary *attributes = run.attributes;
NSURL *linkURL = attributes[@"NSLink"];
if (linkURL) {
// Draw underline for link
CGRect runFrame = run.frame;
CGFloat underlineY = runFrame.origin.y;
CGContextSetStrokeColorWithColor(context, [UIColor blueColor].CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextMoveToPoint(context, runFrame.origin.x, underlineY);
CGContextAddLineToPoint(context, CGRectGetMaxX(runFrame), underlineY);
CGContextStrokePath(context);
}
}
}
}
/**
* Draws selection highlight.
*/
- (void)drawSelectionInContext:(CGContextRef)context options:(NSDictionary *)options {
NSArray *selectedRanges = options[@"selectedRanges"];
UIColor *selectionColor = options[@"selectionColor"] ?: [UIColor colorWithRed:0.0
green:0.47
blue:1.0
alpha:0.2];
for (NSValue *rangeValue in selectedRanges) {
NSRange range = [rangeValue rangeValue];
// Get the rects for this range
NSArray<NSValue *> *rects = [self rectsForRange:range];
CGContextSetFillColorWithColor(context, selectionColor.CGColor);
for (NSValue *rectValue in rects) {
CGRect rect = [rectValue CGRectValue];
CGContextFillRect(context, rect);
}
}
}
/**
* Returns rects for a string range.
*/
- (NSArray<NSValue *> *)rectsForRange:(NSRange)range {
NSMutableArray<NSValue *> *rects = [NSMutableArray array];
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
// Get the x positions for the range within this line
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
CGRect lineRect = CGRectMake(
line.origin.x + startX,
line.origin.y - line.descent,
endX - startX,
line.height
);
[rects addObject:[NSValue valueWithRect:lineRect]];
}
}
return [rects copy];
}
#pragma mark - Hit Testing
/**
* Returns the string index at the given point.
*
* Uses CTLineGetStringIndexForPosition to find which character
* is at the given point.
*
* @param point The point to test
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
// 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 rect for the given string index.
*/
- (CGRect)rectForStringIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
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,
line.height);
}
}
return CGRectNull;
}
/**
* Returns the cursor position for a given string index.
*/
- (CGRect)cursorRectForIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index <= range.location + range.length) {
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGRectMake(line.origin.x + x - 1,
line.origin.y - line.descent,
2,
line.height);
}
}
return CGRectNull;
}
#pragma mark - Text Geometry
/**
* Returns the baseline origin for a given string index.
*/
- (CGPoint)baselineOriginForStringIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGPointMake(line.origin.x + x, line.origin.y);
}
}
return CGPointZero;
}
/**
* Returns the frame for a given string range.
*/
- (CGRect)frameForStringRange:(NSRange)range {
NSArray<NSValue *> *rects = [self rectsForRange:range];
if ([rects count] == 0) {
return CGRectNull;
}
// Union all rects
CGRect result = CGRectNull;
for (NSValue *rectValue in rects) {
result = CGRectUnion(result, [rectValue CGRectValue]);
}
return result;
}
/**
* Returns the string range for a given rect.
*/
- (NSRange)stringRangeForRect:(CGRect)rect {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSUInteger start = NSNotFound;
NSUInteger end = NSNotFound;
for (DTCoreTextLayoutLine *line in allLines) {
CGRect lineRect = CGRectMake(
line.origin.x,
line.origin.y - line.descent,
line.width,
line.height
);
if (CGRectIntersectsRect(rect, lineRect)) {
if (start == NSNotFound) {
start = line.stringRange.location;
}
end = line.stringRange.location + line.stringRange.length;
}
}
if (start != NSNotFound && end != NSNotFound) {
return NSMakeRange(start, end - start);
}
return NSMakeRange(NSNotFound, 0);
}
#pragma mark - Line Geometry
/**
* Returns the line origins.
*/
- (NSArray<NSValue *> *)lineOrigins {
if (_cachedLineOrigins) {
return _cachedLineOrigins;
}
[_lock lock];
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSMutableArray<NSValue *> *origins = [NSMutableArray arrayWithCapacity:[allLines count]];
for (DTCoreTextLayoutLine *line in allLines) {
[origins addObject:[NSValue valueWithCGPoint:line.origin]];
}
_cachedLineOrigins = [origins copy];
[_lock unlock];
return _cachedLineOrigins;
}
/**
* Returns the line frames.
*/
- (NSArray<NSValue *> *)lineFrames {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSMutableArray<NSValue *> *frames = [NSMutableArray arrayWithCapacity:[allLines count]];
for (DTCoreTextLayoutLine *line in allLines) {
CGRect lineFrame = CGRectMake(
line.origin.x,
line.origin.y - line.descent,
line.width,
line.height
);
[frames addObject:[NSValue valueWithRect:lineFrame]];
}
return [frames copy];
}
#pragma mark - Content Height
/**
* Returns the rendered content height.
*/
- (CGFloat)contentHeight {
[_lock lock];
if (_contentHeightCached) {
[_lock unlock];
return _cachedContentHeight;
}
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
if ([allLines count] == 0) {
_cachedContentHeight = 0;
_contentHeightCached = YES;
[_lock unlock];
return 0;
}
// Find the lowest point of the last line
DTCoreTextLayoutLine *lastLine = [allLines lastObject];
CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent;
// Content height is from top of frame to bottom of last line
_cachedContentHeight = _frame.size.height - lastLineBottom;
_contentHeightCached = YES;
[_lock unlock];
return _cachedContentHeight;
}
- (CGFloat)maximumY {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
if ([allLines count] == 0) {
return _frame.origin.y + _frame.size.height;
}
DTCoreTextLayoutLine *firstLine = [allLines firstObject];
return firstLine.origin.y + firstLine.ascent;
}
- (CGFloat)minimumY {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
if ([allLines count] == 0) {
return _frame.origin.y;
}
DTCoreTextLayoutLine *lastLine = [allLines lastObject];
return lastLine.origin.y - lastLine.descent;
}
#pragma mark - Truncation
/**
* Returns whether the frame is truncated.
*/
- (BOOL)isTruncated {
// Check if the attributed string extends beyond the visible range
NSUInteger stringEnd = _range.location + _range.length;
NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length;
return visibleEnd < stringEnd;
}
/**
* Returns the truncation string range.
*/
- (NSRange)truncatedStringRange {
if (![self isTruncated]) {
return NSMakeRange(NSNotFound, 0);
}
NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length;
NSUInteger stringEnd = _range.location + _range.length;
return NSMakeRange(visibleEnd, stringEnd - visibleEnd);
}
#pragma mark - Layout Updates
/**
* Invalidates the layout, forcing recalculation on next access.
*/
- (void)invalidateLayout {
[_lock lock];
_linesExtracted = NO;
_glyphRunsExtracted = NO;
_contentHeightCached = NO;
_cachedLines = nil;
_cachedGlyphRuns = nil;
_cachedLineOrigins = nil;
_needsLayout = YES;
[_lock unlock];
}
/**
* Forces a layout pass.
*/
- (void)layoutIfNeeded {
if (_needsLayout) {
[self extractLines];
_needsLayout = NO;
}
}
- (NSUInteger)stringIndex {
return _range.location + _range.length;
}
@end
@@ -0,0 +1,226 @@
//
// DTCoreTextLayoutLine.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// This class represents a single line of laid-out text.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextGlyphRun;
@class DTCoreTextLayoutFrame;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextLayoutLine - Represents a single line of laid-out text.
*
* This class wraps CTLine and provides high-level access to the
* layout of a single line. It contains an array of DTCoreTextGlyphRun objects
* and provides methods for accessing typographic metrics and hit testing.
*
* Key features:
* - Wraps CTLine for high-level access
* - Contains array of DTCoreTextGlyphRun objects
* - Provides typographic metrics (ascent, descent, leading, width)
* - Supports hit testing within the line
* - Handles baseline offsets for superscript/subscript
*/
@interface DTCoreTextLayoutLine : NSObject
#pragma mark - Properties
/** The CTLine reference */
@property (nonatomic, assign, readonly) CTLineRef ctLine;
/** The origin point of this line in the frame */
@property (nonatomic, assign) CGPoint origin;
/** The range of characters in this line */
@property (nonatomic, assign, readonly) NSRange stringRange;
/** The index of this line in the frame */
@property (nonatomic, assign, readonly) NSUInteger lineIndex;
/** Ascent of the line */
@property (nonatomic, assign, readonly) CGFloat ascent;
/** Descent of the line */
@property (nonatomic, assign, readonly) CGFloat descent;
/** Leading of the line */
@property (nonatomic, assign, readonly) CGFloat leading;
/** Total height of the line (ascent + descent + leading) */
@property (nonatomic, assign, readonly) CGFloat height;
/** Width of the line */
@property (nonatomic, assign, readonly) CGFloat width;
/** Trailing whitespace width */
@property (nonatomic, assign, readonly) CGFloat trailingWhitespaceWidth;
/** Array of glyph runs in this line */
@property (nonatomic, strong, readonly) NSArray<DTCoreTextGlyphRun *> *glyphRuns;
/** The baseline offset (for superscript/subscript) */
@property (nonatomic, assign) CGFloat baselineOffset;
/** Whether this line is the last line in a paragraph */
@property (nonatomic, assign) BOOL isLastLineInParagraph;
/** Whether this line is a line break */
@property (nonatomic, assign) BOOL isLineBreak;
/** The paragraph style for this line */
@property (nonatomic, strong, nullable) NSDictionary *paragraphStyle;
/** Additional metrics for the line */
@property (nonatomic, strong, nullable) NSDictionary *metrics;
#pragma mark - Initialization
/**
* Initialize with a CTLine and origin.
*
* @param ctLine The CoreText line
* @param origin The origin point
* @param range The string range
* @param index The line index
* @return Initialized layout line
*/
- (instancetype)initWithCTLine:(CTLineRef)ctLine
origin:(CGPoint)origin
range:(NSRange)range
index:(NSUInteger)index;
#pragma mark - Glyph Run Access
/**
* Returns the array of glyph runs.
*
* @return Array of DTCoreTextGlyphRun objects
*/
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns;
/**
* Returns the glyph run at the specified index.
*
* @param index The glyph run index
* @return The glyph run at the index, or nil
*/
- (nullable DTCoreTextGlyphRun *)glyphRunAtIndex:(NSUInteger)index;
/**
* Returns the glyph run containing the given string index.
*
* @param index The string index
* @return The glyph run containing the index, or nil
*/
- (nullable DTCoreTextGlyphRun *)glyphRunContainingStringIndex:(NSUInteger)index;
#pragma mark - Typographic Metrics
/**
* Returns the typographic bounds of the line.
*
* @param ascent Output parameter for ascent
* @param descent Output parameter for descent
* @param leading Output parameter for leading
* @return The width of the line
*/
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading;
/**
* Returns the bounds of the line (bounding box).
*/
- (CGRect)bounds;
/**
* Returns the frame of the line (position + bounds).
*/
- (CGRect)frame;
#pragma mark - Hit Testing
/**
* Returns the string index at the given point within this line.
*
* @param point The point to test (in the line's coordinate system)
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
/**
* Returns the offset for a given string index.
*
* @param index The string index
* @return The horizontal offset
*/
- (CGFloat)offsetForStringIndex:(NSUInteger)index;
/**
* Returns the rect for a given string index.
*
* @param index The string index
* @return The rect for the character
*/
- (CGRect)rectForStringIndex:(NSUInteger)index;
#pragma mark - String Operations
/**
* Returns the substring represented by this line.
*/
- (nullable NSString *)substringFromAttributedString:(NSAttributedString *)attributedString;
/**
* Returns the attributes at a given string index.
*/
- (nullable NSDictionary *)attributesAtIndex:(NSUInteger)index
fromAttributedString:(NSAttributedString *)attributedString;
#pragma mark - Line Comparison
/**
* Compares this line to another line for ordering.
*/
- (NSComparisonResult)compareToLine:(DTCoreTextLayoutLine *)otherLine;
/**
* Returns whether this line contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index;
/**
* Returns whether this line intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range;
#pragma mark - Drawing
/**
* Draws this line into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context;
/**
* Draws this line with a specific color.
*
* @param context The CGContext to draw into
* @param color The text color
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,489 @@
//
// DTCoreTextLayoutLine.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextLayoutLine
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextLayoutLine.h"
#import "DTCoreTextGlyphRun.h"
#import <CoreText/CoreText.h>
#pragma mark - Private Interface
@interface DTCoreTextLayoutLine () {
// Cached glyph runs
NSArray<DTCoreTextGlyphRun *> *_cachedGlyphRuns;
// Whether glyph runs have been extracted
BOOL _glyphRunsExtracted;
// Cached typographic metrics
CGFloat _cachedAscent;
CGFloat _cachedDescent;
CGFloat _cachedLeading;
CGFloat _cachedWidth;
BOOL _metricsCached;
// Internal lock
NSLock *_lock;
}
@end
#pragma mark - DTCoreTextLayoutLine Implementation
@implementation DTCoreTextLayoutLine
#pragma mark - Lifecycle
- (instancetype)initWithCTLine:(CTLineRef)ctLine
origin:(CGPoint)origin
range:(NSRange)range
index:(NSUInteger)index {
self = [super init];
if (self) {
_lock = [[NSLock alloc] init];
// Store the CTLine with a retain
if (ctLine) {
_ctLine = (CTLineRef)CFRetain(ctLine);
}
_origin = origin;
_stringRange = range;
_lineIndex = index;
// Initialize state
_glyphRunsExtracted = NO;
_metricsCached = NO;
_baselineOffset = 0;
_isLastLineInParagraph = NO;
_isLineBreak = NO;
}
return self;
}
- (void)dealloc {
if (_ctLine) {
CFRelease(_ctLine);
_ctLine = NULL;
}
}
#pragma mark - Glyph Run Extraction
/**
* Extracts glyph runs from the CTLine.
*
* This method iterates through the CTLine's runs and creates
* DTCoreTextGlyphRun wrapper objects.
*
* Algorithm:
* 1. Get array of CTRuns from CTLine
* 2. For each CTRun:
* a. Get its string range
* b. Get glyph positions and advances
* c. Create DTCoreTextGlyphRun wrapper
* 3. Cache the results
*/
- (void)extractGlyphRuns {
if (_glyphRunsExtracted || !_ctLine) {
return;
}
[_lock lock];
// Get the runs from the CTLine
CFArrayRef ctRuns = CTLineGetGlyphRuns(_ctLine);
if (!ctRuns) {
_cachedGlyphRuns = @[];
_glyphRunsExtracted = YES;
[_lock unlock];
return;
}
CFIndex runCount = CFArrayGetCount(ctRuns);
if (runCount == 0) {
_cachedGlyphRuns = @[];
_glyphRunsExtracted = YES;
[_lock unlock];
return;
}
NSMutableArray<DTCoreTextGlyphRun *> *runs = [NSMutableArray arrayWithCapacity:runCount];
// Calculate the baseline x offset for this line
CGFloat lineXOffset = _origin.x;
for (CFIndex i = 0; i < runCount; i++) {
CTRunRef ctRun = CFArrayGetValueAtIndex(ctRuns, i);
// Get the string range for this run
CFRange cfRange = CTRunGetStringRange(ctRun);
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
// Get the run's typographic bounds
CGFloat ascent, descent, leading;
double width = CTRunGetTypographicBounds(ctRun, CFRangeMake(0, 0), &ascent, &descent, &leading);
// Get the positions of the glyphs
CFIndex glyphCount = CTRunGetGlyphCount(ctRun);
CGPoint *positions = (CGPoint *)malloc(sizeof(CGPoint) * glyphCount);
CTRunGetPositions(ctRun, CFRangeMake(0, 0), positions);
// Get the attributes
NSDictionary *attributes = (__bridge NSDictionary *)CTRunGetAttributes(ctRun);
// Calculate the run's frame
CGRect runFrame;
if (glyphCount > 0) {
CGFloat minX = positions[0].x;
CGFloat maxX = positions[glyphCount - 1].x + width / glyphCount; // Approximate
// More accurate: use the actual glyph advances
CGGlyph *glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * glyphCount);
CTRunGetGlyphs(ctRun, CFRangeMake(0, 0), glyphs);
// Calculate actual bounds using the glyph advances
CGFloat *advances = (CGFloat *)malloc(sizeof(CGFloat) * glyphCount);
CTFontRef font = (__bridge CTFontRef)attributes[(__bridge NSString *)kCTFontAttributeName];
if (font) {
CTFontGetAdvancesForGlyphs(font, kCTFontOrientationHorizontal, glyphs, advances, glyphCount);
}
// Recalculate max X using actual advances
maxX = minX;
for (CFIndex j = 0; j < glyphCount; j++) {
maxX += advances[j];
}
free(glyphs);
free(advances);
runFrame = CGRectMake(
lineXOffset + minX,
_origin.y - descent,
maxX - minX,
ascent + descent
);
} else {
runFrame = CGRectZero;
}
free(positions);
// Create glyph run wrapper
DTCoreTextGlyphRun *glyphRun = [[DTCoreTextGlyphRun alloc]
initWithCTRun:ctRun
frame:runFrame
range:range
attributes:attributes
index:i];
[runs addObject:glyphRun];
}
_cachedGlyphRuns = [runs copy];
_glyphRunsExtracted = YES;
[_lock unlock];
}
/**
* Returns the array of glyph runs.
*/
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns {
[self extractGlyphRuns];
return _cachedGlyphRuns;
}
- (DTCoreTextGlyphRun *)glyphRunAtIndex:(NSUInteger)index {
NSArray *runs = [self glyphRuns];
if (index < [runs count]) {
return runs[index];
}
return nil;
}
/**
* Returns the glyph run containing the given string index.
*/
- (DTCoreTextGlyphRun *)glyphRunContainingStringIndex:(NSUInteger)index {
NSArray<DTCoreTextGlyphRun *> *allRuns = [self glyphRuns];
for (DTCoreTextGlyphRun *run in allRuns) {
NSRange range = run.stringRange;
if (index >= range.location && index < range.location + range.length) {
return run;
}
}
return nil;
}
#pragma mark - Typographic Metrics
/**
* Calculates and caches typographic metrics.
*/
- (void)calculateMetrics {
if (_metricsCached || !_ctLine) {
return;
}
[_lock lock];
// Get typographic bounds from CTLine
_cachedWidth = CTLineGetTypographicBounds(_ctLine, &_cachedAscent, &_cachedDescent, &_cachedLeading);
// Get trailing whitespace width
_trailingWhitespaceWidth = CTLineGetTrailingWhitespaceWidth(_ctLine);
_metricsCached = YES;
[_lock unlock];
}
- (CGFloat)ascent {
[self calculateMetrics];
return _cachedAscent;
}
- (CGFloat)descent {
[self calculateMetrics];
return _cachedDescent;
}
- (CGFloat)leading {
[self calculateMetrics];
return _cachedLeading;
}
- (CGFloat)height {
return [self ascent] + [self descent] + [self leading];
}
- (CGFloat)width {
[self calculateMetrics];
return _cachedWidth;
}
/**
* Returns the typographic bounds of the line.
*/
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading {
[self calculateMetrics];
if (ascent) *ascent = _cachedAscent;
if (descent) *descent = _cachedDescent;
if (leading) *leading = _cachedLeading;
return _cachedWidth;
}
/**
* Returns the bounds of the line (bounding box).
*/
- (CGRect)bounds {
[self calculateMetrics];
return CGRectMake(0, -_cachedDescent, _cachedWidth, [self height]);
}
/**
* Returns the frame of the line (position + bounds).
*/
- (CGRect)frame {
return CGRectMake(_origin.x, _origin.y - _cachedDescent, _cachedWidth, [self height]);
}
#pragma mark - Hit Testing
/**
* Returns the string index at the given point within this line.
*
* Uses CTLineGetStringIndexForPosition to find which character
* is at the given point.
*
* @param point The point to test (in the line's coordinate system)
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
if (!_ctLine) {
return NSNotFound;
}
// Convert point to line-relative coordinates
CGPoint linePoint = CGPointMake(point.x - _origin.x, point.y - _origin.y);
// Use CTLine to find the character index
CFIndex index = CTLineGetStringIndexForPosition(_ctLine, linePoint);
if (index == kCFNotFound) {
return NSNotFound;
}
return (NSUInteger)index;
}
/**
* Returns the offset for a given string index.
*
* @param index The string index
* @return The horizontal offset from the line's origin
*/
- (CGFloat)offsetForStringIndex:(NSUInteger)index {
if (!_ctLine) {
return 0;
}
// CTLineGetOffsetForStringIndex returns the offset from the line's origin
CGFloat offset = CTLineGetOffsetForStringIndex(_ctLine, index, NULL);
return offset;
}
/**
* Returns the rect for a given string index.
*/
- (CGRect)rectForStringIndex:(NSUInteger)index {
CGFloat xOffset = [self offsetForStringIndex:index];
return CGRectMake(
_origin.x + xOffset,
_origin.y - _cachedDescent,
1, // Width of 1 character
[self height]
);
}
#pragma mark - String Operations
/**
* Returns the substring represented by this line.
*/
- (NSString *)substringFromAttributedString:(NSAttributedString *)attributedString {
if (!attributedString || _stringRange.location >= [attributedString length]) {
return nil;
}
// Clamp the range to the string length
NSUInteger maxLength = [attributedString length] - _stringRange.location;
NSUInteger length = MIN(_stringRange.length, maxLength);
return [[attributedString string] substringWithRange:NSMakeRange(_stringRange.location, length)];
}
/**
* Returns the attributes at a given string index.
*/
- (NSDictionary *)attributesAtIndex:(NSUInteger)index
fromAttributedString:(NSAttributedString *)attributedString {
if (!attributedString || index >= [attributedString length]) {
return nil;
}
return [attributedString attributesAtIndex:index effectiveRange:NULL];
}
#pragma mark - Line Comparison
/**
* Compares this line to another line for ordering.
*/
- (NSComparisonResult)compareToLine:(DTCoreTextLayoutLine *)otherLine {
// Compare by line index first
if (_lineIndex < otherLine.lineIndex) {
return NSOrderedAscending;
} else if (_lineIndex > otherLine.lineIndex) {
return NSOrderedDescending;
}
// If same index, compare by string range location
if (_stringRange.location < otherLine.stringRange.location) {
return NSOrderedAscending;
} else if (_stringRange.location > otherLine.stringRange.location) {
return NSOrderedDescending;
}
return NSOrderedSame;
}
/**
* Returns whether this line contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index {
return index >= _stringRange.location &&
index < _stringRange.location + _stringRange.length;
}
/**
* Returns whether this line intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range {
NSRange intersection = NSIntersectionRange(_stringRange, range);
return intersection.length > 0;
}
#pragma mark - Drawing
/**
* Draws this line into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context {
if (!context || !_ctLine) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Move to the line's origin
CGContextSetTextPosition(context, _origin.x, _origin.y);
// Draw the line
CTLineDraw(_ctLine, context);
// Restore graphics state
CGContextRestoreGState(context);
}
/**
* Draws this line with a specific color.
*
* @param context The CGContext to draw into
* @param color The text color
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color {
if (!context || !_ctLine || !color) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Set the text color
CGContextSetFillColorWithColor(context, color.CGColor);
// Move to the line's origin
CGContextSetTextPosition(context, _origin.x, _origin.y);
// Draw the line
CTLineDraw(_ctLine, context);
// Restore graphics state
CGContextRestoreGState(context);
}
@end
+125
View File
@@ -0,0 +1,125 @@
//
// DTCoreTextLayouter.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// WeRead has customized it for their reading engine.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextLayoutFrame;
@class NSAttributedString;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextLayouter - CoreText typesetter wrapper.
*
* This is the original DTCoreText layouter class, customized by WeRead.
* It wraps CTTypesetter and CTFramesetter to provide high-level
* text layout functionality.
*
* Key features:
* - Creates CTTypesetter from attributed string
* - Manages typesetter lifecycle
* - Produces DTCoreTextLayoutFrame objects
* - Supports caching of layout frames
* - Handles string updates efficiently
*/
@interface DTCoreTextLayouter : NSObject
#pragma mark - Properties
/** The attributed string to be laid out */
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
/** Cache for layout frames */
@property (nonatomic, strong, readonly) NSCache *layoutFrameCache;
/** Array of created layout frames */
@property (nonatomic, strong, readonly) NSMutableArray<DTCoreTextLayoutFrame *> *layoutFrames;
#pragma mark - Initialization
/**
* Initialize with an attributed string.
*
* @param attributedString The text with styling attributes
* @return Initialized layouter instance
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString;
#pragma mark - Typesetter Management
/**
* Returns the internal CTTypesetter.
* Creates one if it doesn't exist.
*
* @return The CTTypesetter reference
*/
- (CTTypesetterRef)typesetter;
/**
* Invalidates the current typesetter.
* Called when the attributed string changes.
*/
- (void)invalidateTypesetter;
#pragma mark - Layout Frame Creation
/**
* Create a layout frame for a given string range within the specified rect.
*
* @param range The range of the attributed string to lay out
* @param frame The bounding rectangle for the layout
* @return A new DTCoreTextLayoutFrame
*/
- (DTCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame;
/**
* Suggest a line break for the given range and width.
*
* @param startIndex The starting index
* @param width The available width
* @return The suggested line break index
*/
- (NSUInteger)suggestLineBreakStartIndex:(NSUInteger)startIndex
width:(CGFloat)width;
/**
* Suggest a fitting string length for pagination.
*
* @param startIndex The starting index
* @param constraints Size constraints
* @return The suggested fitting length
*/
- (NSUInteger)stringIndexFittingLengthForWidth:(CGFloat)width
startIndex:(NSUInteger)startIndex;
#pragma mark - Frame Caching
/**
* Returns a cached layout frame for the given key.
*/
- (nullable DTCoreTextLayoutFrame *)cachedLayoutFrameForKey:(NSString *)key;
/**
* Caches a layout frame with the given key.
*/
- (void)cacheLayoutFrame:(DTCoreTextLayoutFrame *)frame
forKey:(NSString *)key;
/**
* Clears the layout frame cache.
*/
- (void)clearLayoutFrameCache;
@end
NS_ASSUME_NONNULL_END
+280
View File
@@ -0,0 +1,280 @@
//
// DTCoreTextLayouter.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextLayouter
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextLayouter.h"
#import "DTCoreTextLayoutFrame.h"
#import <CoreText/CoreText.h>
#pragma mark - Private Interface
@interface DTCoreTextLayouter () {
// CoreText typesetter - the core text processing engine
CTTypesetterRef _typesetter;
// Track whether the typesetter is valid
BOOL _typesetterValid;
// Internal lock for thread safety
NSLock *_lock;
}
@end
#pragma mark - DTCoreTextLayouter Implementation
@implementation DTCoreTextLayouter
#pragma mark - Lifecycle
- (instancetype)init {
self = [super init];
if (self) {
_layoutFrameCache = [[NSCache alloc] init];
_layoutFrameCache.countLimit = 20; // Cache up to 20 layout frames
_layoutFrames = [NSMutableArray array];
_typesetterValid = NO;
_lock = [[NSLock alloc] init];
}
return self;
}
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString {
self = [self init];
if (self) {
_attributedString = attributedString;
}
return self;
}
- (void)dealloc {
if (_typesetter) {
CFRelease(_typesetter);
_typesetter = NULL;
}
}
#pragma mark - Property Accessors
- (void)setAttributedString:(NSAttributedString *)attributedString {
[_lock lock];
_attributedString = attributedString;
// Invalidate the typesetter when the string changes
_typesetterValid = NO;
// Clear the layout frames array
[_layoutFrames removeAllObjects];
[_lock unlock];
}
#pragma mark - Typesetter Management
/**
* Returns the internal CTTypesetter, creating it if necessary.
*
* CTTypesetter is the low-level CoreText object that performs glyph layout.
* It analyzes the attributed string and prepares it for line-by-line layout.
*
* @return The CTTypesetter reference
*/
- (CTTypesetterRef)typesetter {
[_lock lock];
if (!_typesetterValid || !_typesetter) {
// Release old typesetter if any
if (_typesetter) {
CFRelease(_typesetter);
_typesetter = NULL;
}
if (_attributedString) {
// Create new typesetter from the attributed string
_typesetter = CTTypesetterCreateWithAttributedString(
(__bridge CFAttributedStringRef)_attributedString
);
_typesetterValid = YES;
}
}
CTTypesetterRef result = _typesetter;
[_lock unlock];
return result;
}
/**
* Invalidates the current typesetter.
* Forces recreation on next access.
*/
- (void)invalidateTypesetter {
[_lock lock];
_typesetterValid = NO;
[_lock unlock];
}
#pragma mark - Layout Frame Creation
/**
* Creates a DTCoreTextLayoutFrame for a given string range.
*
* This method creates a layout frame by:
* 1. Getting the typesetter
* 2. Creating a CTFrame for the range within the given rect
* 3. Wrapping it in a DTCoreTextLayoutFrame
*
* @param range Range of the attributed string to lay out
* @param frame Bounding rectangle for the layout
* @return A new DTCoreTextLayoutFrame
*/
- (DTCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame {
CTTypesetterRef typesetter = [self typesetter];
if (!typesetter || range.length == 0) {
return nil;
}
[_lock lock];
// Create a CGPath for the frame bounds
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, frame);
// Create CTFrame from the typesetter
CTFrameRef ctFrame = CTTypesetterCreateFrame(
typesetter,
CFRangeMake(range.location, range.length),
path,
NULL
);
CGPathRelease(path);
if (!ctFrame) {
[_lock unlock];
return nil;
}
// Create layout frame wrapper
DTCoreTextLayoutFrame *layoutFrame = [[DTCoreTextLayoutFrame alloc]
initWithAttributedString:_attributedString
range:range
ctFrame:ctFrame];
CFRelease(ctFrame);
[_layoutFrames addObject:layoutFrame];
[_lock unlock];
return layoutFrame;
}
/**
* Suggests a line break for the given start index and width.
*
* Uses CTTypesetterSuggestLineBreak to determine how many characters
* fit within the given width.
*
* @param startIndex The starting index in the string
* @param width The available width
* @return The suggested line break index
*/
- (NSUInteger)suggestLineBreakStartIndex:(NSUInteger)startIndex
width:(CGFloat)width {
CTTypesetterRef typesetter = [self typesetter];
if (!typesetter) {
return 0;
}
// CTTypesetterSuggestLineBreak returns the number of characters
// that fit in the given width starting from startIndex
CFIndex breakIndex = CTTypesetterSuggestLineBreak(
typesetter,
startIndex,
width
);
return (NSUInteger)breakIndex;
}
/**
* Suggests a fitting string length for pagination.
*
* This method simulates pagination to determine how much text
* fits within the given constraints.
*
* @param width The available width
* @param startIndex The starting index
* @return The suggested fitting length
*/
- (NSUInteger)stringIndexFittingLengthForWidth:(CGFloat)width
startIndex:(NSUInteger)startIndex {
CTTypesetterRef typesetter = [self typesetter];
if (!_attributedString || !typesetter) {
return 0;
}
NSUInteger totalLength = [_attributedString length];
NSUInteger currentIndex = startIndex;
NSUInteger totalFitted = 0;
// Simulate line-by-line layout to find total fitting length
while (currentIndex < totalLength) {
CFIndex lineBreak = CTTypesetterSuggestLineBreak(
typesetter,
currentIndex,
width
);
if (lineBreak <= 0) {
break;
}
totalFitted += lineBreak;
currentIndex += lineBreak;
}
return totalFitted;
}
#pragma mark - Frame Caching
/**
* Returns a cached layout frame for the given key.
*/
- (DTCoreTextLayoutFrame *)cachedLayoutFrameForKey:(NSString *)key {
return [_layoutFrameCache objectForKey:key];
}
/**
* Caches a layout frame with the given key.
*/
- (void)cacheLayoutFrame:(DTCoreTextLayoutFrame *)frame
forKey:(NSString *)key {
if (frame && key) {
[_layoutFrameCache setObject:frame forKey:key];
}
}
/**
* Clears the layout frame cache.
*/
- (void)clearLayoutFrameCache {
[_layoutFrameCache removeAllObjects];
}
@end
@@ -0,0 +1,106 @@
//
// DTHTMLAttributedStringBuilder.h
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered from WeChat Reading (微信读书) binary.
// This class converts HTML DOM into NSAttributedString via SAX-style parsing.
//
#import <Foundation/Foundation.h>
@class DTHTMLParserDelegate;
@class DTHTMLElement;
@class DTCSSStylesheet;
@class DTCoreTextFontDescriptor;
@class DTCoreTextParagraphStyle;
@class WRBook;
@class WRChapter;
// ---------------------------------------------------------------------------
// WeRead custom NSAttributedString attribute keys
// ---------------------------------------------------------------------------
// These keys are used in the resulting attributed string to carry page layout
// and presentation metadata that goes beyond standard DTCoreText attributes.
extern NSString *const DTPageBackgroundColorAttribute;
extern NSString *const DTPageBackgroundImageAttribute;
extern NSString *const DTPageBackgroundImagePathAttribute;
extern NSString *const DTPageBreakAfterAttribute;
extern NSString *const DTPageBreakBeforeAttribute;
extern NSString *const DTPageBreakInsideAvoidAttribute;
extern NSString *const DTPageRelateAttribute;
extern NSString *const DTPageSize;
extern NSString *const DTPageFlippingStyle;
extern NSString *const DTHTMLVerticalCenterAttribute;
extern NSString *const DTHTMLTranslateTagAttribute;
extern NSString *const DTHTMLTranslateNoStyleAttribute;
// ---------------------------------------------------------------------------
// DTHTMLAttributedStringBuilder
// ---------------------------------------------------------------------------
@interface DTHTMLAttributedStringBuilder : NSObject
// --- Initializers ---
/**
Designated initializer.
@param htmlData Raw HTML data (UTF-8 encoded).
@param options Dictionary of build options (base URL, CSS stylesheet, etc.).
*/
- (instancetype)initWithHTML:(NSData *)htmlData
options:(NSDictionary *)options;
/**
Convenience initializer that also supplies a CSS stylesheet.
*/
- (instancetype)initWithHTML:(NSData *)htmlData
cssStyleSheet:(DTCSSStylesheet *)styleSheet
options:(NSDictionary *)options;
// --- Building ---
/**
Triggers the full HTML parse → DOM tree → NSAttributedString pipeline.
Must be called before -generatedAttributedString.
*/
- (void)buildString;
/**
Returns the attributed string produced by the most recent -buildString call.
*/
- (NSAttributedString *)generatedAttributedString;
// --- DTHTMLParser delegate (SAX callbacks) ---
- (void)parser:(id)parser
didStartElement:(NSString *)elementName
attributes:(NSDictionary *)attributeDict
position:(NSUInteger)position;
- (void)parser:(id)parser
foundCDATA:(NSData *)CDATABlock;
- (void)parser:(id)parser
foundCharacters:(NSString *)string
position:(NSUInteger)position;
- (void)parserDidEndDocument:(id)parser;
// --- Tag handler registration (internal) ---
- (void)_registerTagStartHandlers;
- (void)_registerTagEndHandlers;
// --- Properties ---
@property (nonatomic, strong, readonly) NSData *htmlData;
@property (nonatomic, strong, readonly) DTCSSStylesheet *cssStyleSheet;
@property (nonatomic, strong, readonly) NSDictionary *options;
@property (nonatomic, strong, readonly) NSAttributedString *generatedAttributedString;
// WeRead-specific: the book / chapter context used during rendering.
@property (nonatomic, strong) WRBook *book;
@property (nonatomic, strong) WRChapter *chapter;
@end
@@ -0,0 +1,643 @@
//
// DTHTMLAttributedStringBuilder.m
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered pseudo-implementation from WeChat Reading binary.
// This file reconstructs the full HTML → NSAttributedString pipeline.
//
// Pipeline overview:
// 1. Parse raw HTML via DTHTMLParser (SAX-style).
// 2. Each SAX event (startElement / endElement / foundCharacters / CDATA)
// builds or mutates DTHTMLElement nodes in a DOM tree.
// 3. After parsing completes, -_buildString walks the DOM tree and calls
// -[DTHTMLElement attributedString] recursively.
// 4. The resulting NSAttributedString is stored in _generatedAttributedString.
//
#import "DTHTMLAttributedStringBuilder.h"
#import "DTHTMLParserDelegate.h"
#import "DTHTMLElement.h"
#import "DTCSSStylesheet.h"
#import "DTCoreTextFontDescriptor.h"
#import "DTCoreTextParagraphStyle.h"
#import "DTHTMLParser.h"
#import "WRBook.h"
#import "WRChapter.h"
// ---------------------------------------------------------------------------
// WeRead custom attribute key definitions
// ---------------------------------------------------------------------------
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColorAttribute";
NSString *const DTPageBackgroundImageAttribute = @"DTPageBackgroundImageAttribute";
NSString *const DTPageBackgroundImagePathAttribute = @"DTPageBackgroundImagePathAttribute";
NSString *const DTPageBreakAfterAttribute = @"DTPageBreakAfterAttribute";
NSString *const DTPageBreakBeforeAttribute = @"DTPageBreakBeforeAttribute";
NSString *const DTPageBreakInsideAvoidAttribute = @"DTPageBreakInsideAvoidAttribute";
NSString *const DTPageRelateAttribute = @"DTPageRelateAttribute";
NSString *const DTPageSize = @"DTPageSize";
NSString *const DTPageFlippingStyle = @"DTPageFlippingStyle";
NSString *const DTHTMLVerticalCenterAttribute = @"DTHTMLVerticalCenterAttribute";
NSString *const DTHTMLTranslateTagAttribute = @"DTHTMLTranslateTagAttribute";
NSString *const DTHTMLTranslateNoStyleAttribute = @"DTHTMLTranslateNoStyleAttribute";
// ---------------------------------------------------------------------------
#pragma mark - Private interface
// ---------------------------------------------------------------------------
@interface DTHTMLAttributedStringBuilder ()
{
// ---- ivar: the SAX parser delegate that accumulates the DOM tree ----
DTHTMLParserDelegate *_parserDelegate;
// ---- Cached input ----
NSData *_htmlData;
DTCSSStylesheet *_cssStyleSheet;
NSDictionary *_options;
// ---- Result ----
NSAttributedString *_generatedAttributedString;
// ---- WeRead book/chapter context ----
WRBook *_book;
WRChapter *_chapter;
}
@end
// ---------------------------------------------------------------------------
#pragma mark - DTHTMLParserDelegate (internal helper)
// ---------------------------------------------------------------------------
// In the actual binary this is a separate class whose ivars include all the
// mutable state needed during parsing. We define it here to show the fields
// that DTHTMLAttributedStringBuilder delegates to.
@interface DTHTMLParserDelegate : NSObject
{
// Current tag handlers (block-based dispatch tables keyed on tag name)
NSData *_tagStartHandlers; // actually a block map
NSDictionary *_tagEndHandlers;
// CSS stylesheet applied during parsing
DTCSSStylesheet *_cssStyleSheet;
// Base URL for resolving relative links / images
NSURL *_baseURL;
// Default font / paragraph descriptors used when no CSS overrides exist
DTCoreTextFontDescriptor *_defaultFontDescriptor;
DTCoreTextParagraphStyle *_defaultParagraphStyle;
// The root of the DOM tree being built
DTHTMLElement *_rootElement;
// Stack of open elements (for nesting / parent resolution)
NSMutableDictionary *_elementStack; // index → DTHTMLElement
// Accumulated text for the current text run
NSMutableDictionary *_currentTextBuffer;
// "Current" element pointers (set during SAX traversal)
DTHTMLElement *_currentElement;
DTHTMLElement *_parentElement;
DTHTMLElement *_lastInlineElement;
DTHTMLElement *_lastBlockElement;
// WeRead-specific rendering context
WRBook *_book;
WRChapter *_chapter;
// Final output accumulator
NSMutableAttributedString *_outputString;
// Image / view references (for lazy image loading)
UIImageView *_currentImageView;
UIView *_imageContainerView;
UIView *_currentView;
NSString *_currentImageSrc;
UIView *_parentView;
UIView *_rootView;
// Collected tag list for post-processing
NSArray *_tagOrder;
}
// Methods used during SAX parsing
- (void)_registerTagStartHandlers;
- (void)_registerTagEndHandlers;
@end
@implementation DTHTMLParserDelegate
// --------------------------------------------------
# pragma mark Handler registration
// --------------------------------------------------
/**
Registers block handlers for HTML start tags.
Each handler receives the element name, attributes dict, and position,
then creates or configures the appropriate DTHTMLElement subclass.
WeRead registers custom handlers for their proprietary CSS attributes:
- wr-vertical-center-style → DTHTMLVerticalCenterAttribute
- weread-page-relate → DTPageRelateAttribute
- avoidPageBreakInside → DTPageBreakInsideAvoidAttribute
- DTPageBreakAfter / DTPageBreakBefore
- DTPageBackgroundColor / DTPageBackgroundImage
*/
- (void)_registerTagStartHandlers
{
// Pseudo-code: build a dictionary mapping tag names to handler blocks.
//
// _tagStartHandlers = @{
// @"p" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"div" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"img" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"br" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"a" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"h1" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// ...
// // WeRead custom tags / attributes handled here
// };
//
// Each handler typically:
// 1. Creates a new DTHTMLElement.
// 2. Sets its tagName, attributes, parent.
// 3. Calls -[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:].
// 4. Pushes the element onto _elementStack.
// 5. Updates _currentElement.
}
/**
Registers block handlers for HTML end tags.
Responsible for:
- Popping the element from the stack.
- Finalizing inline text runs.
- Applying paragraph-level styles.
- Handling DTPageBreakAfter / Before attributes.
*/
- (void)_registerTagEndHandlers
{
// Pseudo-code: analogous to start handlers but for closing tags.
//
// _tagEndHandlers = @{
// @"p" : ^(NSString *tag) { ... },
// @"div" : ^(NSString *tag) { ... },
// ...
// };
//
// Each handler typically:
// 1. Pops the current element from the stack.
// 2. Calls -[DTHTMLElement interpretAttributes] to finalize.
// 3. If element has DTPageBreakAfter, inserts page break marker.
// 4. Sets _currentElement back to the parent.
}
@end
// ---------------------------------------------------------------------------
#pragma mark - DTHTMLAttributedStringBuilder implementation
// ---------------------------------------------------------------------------
@implementation DTHTMLAttributedStringBuilder
// --------------------------------------------------
# pragma mark Initialization
// --------------------------------------------------
- (instancetype)initWithHTML:(NSData *)htmlData
options:(NSDictionary *)options
{
self = [super init];
if (self) {
_htmlData = [htmlData copy];
_options = [options copy] ?: @{};
// Create the parser delegate and configure it.
_parserDelegate = [[DTHTMLParserDelegate alloc] init];
// If a CSS stylesheet is provided in options, install it.
DTCSSStylesheet *sheet = _options[@"DTDefaultCSSStyleSheet"];
if (sheet) {
_cssStyleSheet = sheet;
_parserDelegate->_cssStyleSheet = sheet;
}
// Base URL for resolving <img src>, <a href>, etc.
NSURL *baseURL = _options[@"DTBaseURL"];
if (baseURL) {
_parserDelegate->_baseURL = baseURL;
}
// Default font descriptor (system font fallback).
DTCoreTextFontDescriptor *fontDesc = _options[@"DTDefaultFontDescriptor"];
if (fontDesc) {
_parserDelegate->_defaultFontDescriptor = fontDesc;
}
// Default paragraph style.
DTCoreTextParagraphStyle *paraStyle = _options[@"DTDefaultParagraphStyle"];
if (paraStyle) {
_parserDelegate->_defaultParagraphStyle = paraStyle;
}
// Register the tag handler dispatch tables.
[_parserDelegate _registerTagStartHandlers];
[_parserDelegate _registerTagEndHandlers];
}
return self;
}
- (instancetype)initWithHTML:(NSData *)htmlData
cssStyleSheet:(DTCSSStylesheet *)styleSheet
options:(NSDictionary *)options
{
// Merge stylesheet into options so the designated init picks it up.
NSMutableDictionary *merged = [options mutableCopy] ?: [NSMutableDictionary dictionary];
if (styleSheet) {
merged[@"DTDefaultCSSStyleSheet"] = styleSheet;
}
return [self initWithHTML:htmlData options:merged];
}
// --------------------------------------------------
# pragma mark Build pipeline
// --------------------------------------------------
/**
Main entry point. Kicks off the SAX parser; when parsing completes the
delegate has built a DOM tree of DTHTMLElement nodes. Then -_buildString
converts that tree into the final NSAttributedString.
*/
- (void)buildString
{
// Step 1: Create a SAX parser and point it at our delegate.
DTHTMLParser *parser = [[DTHTMLParser alloc] initWithData:_htmlData];
parser.delegate = _parserDelegate;
// Step 2: Parse. This triggers the delegate callbacks below.
[parser parse];
// Step 3: Convert the DOM tree to an attributed string.
[self _buildString];
}
/**
Walks the DOM tree rooted at _parserDelegate->_rootElement and recursively
calls -[DTHTMLElement attributedString] to produce the final output.
*/
- (void)_buildString
{
DTHTMLElement *root = _parserDelegate->_rootElement;
if (!root) {
_generatedAttributedString = [[NSAttributedString alloc] initWithString:@""];
return;
}
// Recursively convert the DOM tree.
// DTHTMLElement's -attributedString walks children and concatenates.
NSAttributedString *result = [root attributedString];
// If the result is nil (empty document), produce an empty string.
if (!result) {
result = [[NSAttributedString alloc] initWithString:@""];
}
_generatedAttributedString = result;
// Store into the delegate's output for external access if needed.
_parserDelegate->_outputString = [result mutableCopy];
}
// --------------------------------------------------
# pragma mark DTHTMLParser delegate callbacks
// --------------------------------------------------
/**
Called by the SAX parser when an opening HTML tag is encountered.
@param parser The DTHTMLParser instance.
@param elementName Tag name (e.g. "p", "div", "img").
@param attributeDict Parsed attributes from the HTML tag.
@param position Character offset in the original HTML data.
*/
- (void)parser:(id)parser
didStartElement:(NSString *)elementName
attributes:(NSDictionary *)attributeDict
position:(NSUInteger)position
{
// Look up the registered handler block for this tag.
// If found, invoke it. Otherwise, fall through to default handling.
DTHTMLElement *newElement = [[DTHTMLElement alloc] initWithTagName:elementName
attributes:attributeDict];
// Set the parent to the current element on the stack.
DTHTMLElement *parent = _parserDelegate->_currentElement;
newElement.parent = parent;
[parent.children addObject:newElement];
// Push onto the element stack.
_parserDelegate->_currentElement = newElement;
// Apply inline style attribute (style="...") and any CSS rules
// matching this element.
NSDictionary *styleDict = [self _resolveStyleForElement:newElement
attributes:attributeDict];
if (styleDict) {
// Determine if this is a Latin-language book (affects font fallback).
BOOL isLatin = [_parserDelegate->_book isLatinLanguageBook];
[newElement applyStyleDictionary:styleDict isLatinLanguageBook:isLatin];
}
// Handle WeRead-specific custom CSS attributes.
[self _applyWeReadCustomAttributes:newElement fromAttributes:attributeDict];
// Handle special tags.
if ([elementName caseInsensitiveCompare:@"img"] == NSOrderedSame) {
[self _handleImageElement:newElement attributes:attributeDict];
}
else if ([elementName caseInsensitiveCompare:@"br"] == NSOrderedSame) {
[self _handleBRElement:newElement];
}
else if ([elementName caseInsensitiveCompare:@"a"] == NSOrderedSame) {
[self _handleAnchorElement:newElement attributes:attributeDict];
}
// Track last block vs. inline element for layout decisions.
if ([newElement isBlockElement]) {
_parserDelegate->_lastBlockElement = newElement;
} else {
_parserDelegate->_lastInlineElement = newElement;
}
}
/**
Called when character data is found between tags.
@param parser The DTHTMLParser instance.
@param string The character data.
@param position Character offset in the original HTML.
*/
- (void)parser:(id)parser
foundCharacters:(NSString *)string
position:(NSUInteger)position
{
if (!string || string.length == 0) {
return;
}
// Append to the current text buffer.
// The delegate accumulates text until a closing tag flushes it.
DTHTMLElement *current = _parserDelegate->_currentElement;
if (current) {
[current appendText:string];
}
}
/**
Called when a CDATA section is encountered (e.g. inside <script> or <style>).
WeRead uses CDATA in some book content.
@param parser The DTHTMLParser instance.
@param CDATABlock Raw CDATA bytes.
*/
- (void)parser:(id)parser
foundCDATA:(NSData *)CDATABlock
{
// CDATA is typically treated as raw text content.
NSString *text = [[NSString alloc] initWithData:CDATABlock
encoding:NSUTF8StringEncoding];
if (text) {
DTHTMLElement *current = _parserDelegate->_currentElement;
if (current) {
[current appendText:text];
}
}
}
/**
Called when the parser finishes parsing the entire document.
*/
- (void)parserDidEndDocument:(id)parser
{
// All elements have been opened and closed.
// The DOM tree is complete in _parserDelegate->_rootElement.
// Post-processing can happen here if needed.
}
// --------------------------------------------------
# pragma mark Style resolution
// --------------------------------------------------
/**
Resolves the effective style dictionary for an element by merging:
1. CSS stylesheet rules matching this element.
2. Inline style="" attribute.
3. Element-specific default styles.
*/
- (NSDictionary *)_resolveStyleForElement:(DTHTMLElement *)element
attributes:(NSDictionary *)attrs
{
NSMutableDictionary *resolved = [NSMutableDictionary dictionary];
// 1. Apply CSS stylesheet rules (class, id, tag selectors).
if (_cssStyleSheet) {
NSDictionary *cssRules = [_cssStyleSheet stylesForElement:element];
if (cssRules) {
[resolved addEntriesFromDictionary:cssRules];
}
}
// 2. Parse inline style attribute.
NSString *inlineStyle = attrs[@"style"];
if (inlineStyle) {
NSDictionary *inlineDict = [self _parseInlineStyle:inlineStyle];
if (inlineDict) {
[resolved addEntriesFromDictionary:inlineDict];
}
}
return resolved.count > 0 ? resolved : nil;
}
/**
Parses a CSS inline style string (e.g. "color:red;font-size:14px")
into a dictionary.
*/
- (NSDictionary *)_parseInlineStyle:(NSString *)styleString
{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
NSArray *declarations = [styleString componentsSeparatedByString:@";"];
for (NSString *decl in declarations) {
NSArray *parts = [decl componentsSeparatedByString:@":"];
if (parts.count == 2) {
NSString *key = [parts[0] stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
NSString *val = [parts[1] stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
if (key.length > 0 && val.length > 0) {
dict[key] = val;
}
}
}
return dict;
}
// --------------------------------------------------
# pragma mark WeRead custom attribute handling
// --------------------------------------------------
/**
Applies WeRead-specific CSS attributes to the element.
These control page layout features unique to WeRead's reading engine.
*/
- (void)_applyWeReadCustomAttributes:(DTHTMLElement *)element
fromAttributes:(NSDictionary *)attrs
{
// wr-vertical-center-style: vertically center content within page
NSString *vCenter = attrs[@"wr-vertical-center-style"];
if (vCenter) {
element.verticalCenterStyle = vCenter;
}
// weread-page-relate: marks content as page-related
NSString *pageRelate = attrs[@"weread-page-relate"];
if (pageRelate) {
element.pageRelate = pageRelate;
}
// avoidPageBreakInside: prevent page breaks within this element
NSString *avoidBreak = attrs[@"avoidPageBreakInside"];
if ([avoidBreak boolValue] || [avoidBreak isEqualToString:@"true"]) {
element.shouldAvoidPageBreakInside = YES;
}
// DTPageBreakAfter: force page break after this element
NSString *breakAfter = attrs[@"DTPageBreakAfter"];
if (breakAfter) {
element.pageBreakAfter = YES;
}
// DTPageBreakBefore: force page break before this element
NSString *breakBefore = attrs[@"DTPageBreakBefore"];
if (breakBefore) {
element.pageBreakBefore = YES;
}
// DTPageBackgroundColor: per-page background color
NSString *bgColor = attrs[@"DTPageBackgroundColor"];
if (bgColor) {
element.pageBackgroundColor = [self _colorFromCSSValue:bgColor];
}
// DTPageBackgroundImage: per-page background image
NSString *bgImage = attrs[@"DTPageBackgroundImage"];
if (bgImage) {
element.pageBackgroundImage = bgImage;
}
}
/**
Converts a CSS color value string to UIColor.
*/
- (UIColor *)_colorFromCSSValue:(NSString *)cssValue
{
// Simplified: real implementation handles hex (#RRGGBB), rgb(), named colors.
if ([cssValue hasPrefix:@"#"]) {
NSString *hex = [cssValue substringFromIndex:1];
unsigned int rgb = 0;
[[NSScanner scannerWithString:hex] scanHexInt:&rgb];
return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0
green:((rgb >> 8) & 0xFF) / 255.0
blue:((rgb >> 0) & 0xFF) / 255.0
alpha:1.0];
}
return nil;
}
// --------------------------------------------------
# pragma mark Special element handlers
// --------------------------------------------------
- (void)_handleImageElement:(DTHTMLElement *)element
attributes:(NSDictionary *)attrs
{
// Resolve image source relative to base URL.
NSString *src = attrs[@"src"];
if (!src) return;
NSURL *baseURL = _parserDelegate->_baseURL;
NSURL *imageURL = [NSURL URLWithString:src relativeToURL:baseURL];
element.imageURL = imageURL;
// Store for lazy loading pipeline.
_parserDelegate->_currentImageSrc = src;
}
- (void)_handleBRElement:(DTHTMLElement *)element
{
// <br> inserts a newline / line break.
element.tagName = @"br";
element.isLineBreak = YES;
}
- (void)_handleAnchorElement:(DTHTMLElement *)element
attributes:(NSDictionary *)attrs
{
// <a href="..."> creates a hyperlink.
NSString *href = attrs[@"href"];
if (href) {
NSURL *baseURL = _parserDelegate->_baseURL;
element.linkURL = [NSURL URLWithString:href relativeToURL:baseURL];
}
}
// --------------------------------------------------
# pragma mark Properties
// --------------------------------------------------
- (NSData *)htmlData
{
return _htmlData;
}
- (DTCSSStylesheet *)cssStyleSheet
{
return _cssStyleSheet;
}
- (NSDictionary *)options
{
return _options;
}
- (NSAttributedString *)generatedAttributedString
{
return _generatedAttributedString;
}
- (WRBook *)book
{
return _parserDelegate->_book;
}
- (void)setBook:(WRBook *)book
{
_parserDelegate->_book = book;
_book = book;
}
- (WRChapter *)chapter
{
return _parserDelegate->_chapter;
}
- (void)setChapter:(WRChapter *)chapter
{
_parserDelegate->_chapter = chapter;
_chapter = chapter;
}
@end
+135
View File
@@ -0,0 +1,135 @@
//
// DTHTMLElement.h
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered from WeChat Reading (微信读书) binary.
// Represents a single HTML element in the DOM tree built during parsing.
// Each node can produce an NSAttributedString via -attributedString.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
@class DTTextAttachment;
@class DTBorderStyle;
@class DTBackgroundImageStyle;
@class DTTableStyle;
@class DTCSSStylesheet;
@class DTCoreTextFontDescriptor;
@class DTCoreTextParagraphStyle;
// ---------------------------------------------------------------------------
// DTHTMLElement
// ---------------------------------------------------------------------------
@interface DTHTMLElement : NSObject
// --- Initialization ---
- (instancetype)initWithTagName:(NSString *)tagName
attributes:(NSDictionary *)attributes;
// --- Tree structure ---
@property (nonatomic, weak) DTHTMLElement *parent;
@property (nonatomic, strong) NSMutableArray *children;
// --- Tag identity ---
@property (nonatomic, copy) NSString *tagName;
@property (nonatomic, copy) NSString *elementId;
@property (nonatomic, strong) NSArray *classNames;
// --- Attributes & style ---
@property (nonatomic, strong) NSDictionary *attributes;
@property (nonatomic, strong) NSDictionary *styleDictionary;
// --- Text content ---
@property (nonatomic, copy) NSString *text;
@property (nonatomic, strong) NSArray *textRuns;
// --- Display properties ---
@property (nonatomic, assign) BOOL isLineBreak;
@property (nonatomic, assign) BOOL isBlockElement;
@property (nonatomic, assign) BOOL shouldAvoidPageBreakInside;
@property (nonatomic, assign) BOOL pageBreakAfter;
@property (nonatomic, assign) BOOL pageBreakBefore;
// --- Font & paragraph ---
@property (nonatomic, strong) DTCoreTextFontDescriptor *fontDescriptor;
@property (nonatomic, strong) DTCoreTextParagraphStyle *paragraphStyle;
// --- Colors ---
@property (nonatomic, strong) UIColor *textColor;
@property (nonatomic, strong) UIColor *backgroundColor;
// --- Links ---
@property (nonatomic, strong) NSURL *linkURL;
// --- Images & attachments ---
@property (nonatomic, strong) NSURL *imageURL;
@property (nonatomic, strong) DTTextAttachment *textAttachment;
// --- WeRead-specific page layout properties ---
@property (nonatomic, copy) NSString *verticalCenterStyle;
@property (nonatomic, copy) NSString *pageRelate;
@property (nonatomic, strong) UIColor *pageBackgroundColor;
@property (nonatomic, copy) NSString *pageBackgroundImage;
// --- Border & background ---
@property (nonatomic, strong) DTBorderStyle *borderStyle;
@property (nonatomic, strong) DTBackgroundImageStyle *backgroundImageStyle;
@property (nonatomic, strong) DTTableStyle *tableStyle;
// --- Additional string fields observed in ivars ---
@property (nonatomic, copy) NSString *cssClass;
@property (nonatomic, copy) NSString *cssId;
@property (nonatomic, copy) NSString *lang;
@property (nonatomic, copy) NSString *direction; // ltr / rtl
@property (nonatomic, copy) NSString *whiteSpace;
@property (nonatomic, copy) NSString *textAlign;
// --- Public methods ---
/**
Applies a CSS style dictionary to this element, resolving font, color,
paragraph style, etc.
@param styleDict The CSS properties to apply.
@param isLatin Whether the content language is Latin-script.
*/
- (void)applyStyleDictionary:(NSDictionary *)styleDict
isLatinLanguageBook:(BOOL)isLatin;
/**
Recursively converts this element and its children into an NSAttributedString.
@return The attributed string representing this subtree.
*/
- (NSAttributedString *)attributedString;
/**
Finalizes attributes after all children have been parsed.
Called when the closing tag is encountered.
*/
- (void)interpretAttributes;
/**
Appends text content to this element (called during SAX foundCharacters:).
*/
- (void)appendText:(NSString *)text;
/**
Returns YES if this element is a void / self-closing element (img, br, hr, etc.)
*/
- (BOOL)isVoidElement;
@end
+917
View File
@@ -0,0 +1,917 @@
//
// DTHTMLElement.m
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered pseudo-implementation from WeChat Reading binary.
// Each DTHTMLElement node represents an HTML tag; it holds resolved style
// properties and can produce an NSAttributedString via -attributedString.
//
// Key responsibilities:
// 1. Store the tag name, attributes, and parent/child tree pointers.
// 2. Apply CSS style dictionaries (from stylesheet or inline style).
// 3. Resolve font descriptors and paragraph styles.
// 4. Convert itself + children into NSAttributedString (recursive).
// 5. Handle WeRead-specific page layout attributes.
//
#import "DTHTMLElement.h"
#import "DTTextAttachment.h"
#import "DTBorderStyle.h"
#import "DTBackgroundImageStyle.h"
#import "DTTableStyle.h"
#import "DTCoreTextFontDescriptor.h"
#import "DTCoreTextParagraphStyle.h"
#import "DTCSSStylesheet.h"
// DTCoreText standard attribute keys (defined elsewhere in DTCoreText)
// extern NSString *const DTTextListsAttribute;
// extern NSString *const DTStrikeOutAttribute;
// extern NSString *const DTUnderlineStyleAttribute;
// extern NSString *const DTLinkAttribute;
// ...
// Void elements: tags that have no closing tag and no children.
static NSSet *_voidElements = nil;
@implementation DTHTMLElement
{
// Mutable text accumulator used during parsing.
NSMutableString *_textBuffer;
}
// --------------------------------------------------
# pragma mark Class initialization
// --------------------------------------------------
+ (void)initialize
{
if (self == [DTHTMLElement class]) {
_voidElements = [NSSet setWithArray:@[
@"area", @"base", @"br", @"col", @"embed", @"hr",
@"img", @"input", @"link", @"meta", @"param",
@"source", @"track", @"wbr"
]];
}
}
// --------------------------------------------------
# pragma mark Initialization
// --------------------------------------------------
- (instancetype)initWithTagName:(NSString *)tagName
attributes:(NSDictionary *)attributes
{
self = [super init];
if (self) {
_tagName = [tagName lowercaseString];
_attributes = [attributes copy] ?: @{};
_children = [NSMutableArray array];
// Parse id and class from attributes.
_elementId = attributes[@"id"];
NSString *classStr = attributes[@"class"];
if (classStr) {
_classNames = [classStr componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
}
// Default display mode: block for most tags, inline for span/em/etc.
_isBlockElement = [self _isDefaultBlockElement:_tagName];
}
return self;
}
- (instancetype)init
{
return [self initWithTagName:nil attributes:nil];
}
// --------------------------------------------------
# pragma mark Default block/inline classification
// --------------------------------------------------
/**
Returns YES for tags that are block-level by default in HTML.
*/
- (BOOL)_isDefaultBlockElement:(NSString *)tag
{
static NSSet *blockTags = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
blockTags = [NSSet setWithArray:@[
@"div", @"p", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6",
@"blockquote", @"ul", @"ol", @"li", @"table", @"tr", @"td",
@"th", @"thead", @"tbody", @"tfoot", @"section", @"article",
@"header", @"footer", @"nav", @"main", @"aside", @"figure",
@"figcaption", @"address", @"pre", @"hr", @"form", @"fieldset",
@"dl", @"dt", @"dd"
]];
});
return [blockTags containsObject:tag];
}
// --------------------------------------------------
# pragma mark Text content management
// --------------------------------------------------
/**
Appends character data to this element's text buffer.
Called by the builder during SAX foundCharacters: events.
*/
- (void)appendText:(NSString *)text
{
if (!text || text.length == 0) return;
if (!_textBuffer) {
_textBuffer = [NSMutableString stringWithString:text];
} else {
[_textBuffer appendString:text];
}
_text = _textBuffer;
}
// --------------------------------------------------
# pragma mark Style application
// --------------------------------------------------
/**
Applies a CSS style dictionary to this element.
This is the core style resolution method. It translates CSS property names
into DTCoreText property objects (font descriptors, paragraph styles, colors).
@param styleDict Dictionary of CSS property → value.
@param isLatin YES if the book language is Latin-script (affects font
fallback and line-height calculations).
*/
- (void)applyStyleDictionary:(NSDictionary *)styleDict
isLatinLanguageBook:(BOOL)isLatin
{
if (!styleDict || styleDict.count == 0) return;
_styleDictionary = styleDict;
// ---- Font properties ----
[self _applyFontProperties:styleDict isLatin:isLatin];
// ---- Text properties ----
[self _applyTextProperties:styleDict];
// ---- Color properties ----
[self _applyColorProperties:styleDict];
// ---- Display / layout properties ----
[self _applyDisplayProperties:styleDict];
// ---- Margin / padding (for paragraph style) ----
[self _applyBoxModelProperties:styleDict];
// ---- WeRead-specific properties ----
[self _applyWeReadProperties:styleDict];
// ---- Border properties ----
[self _applyBorderProperties:styleDict];
// ---- Background properties ----
[self _applyBackgroundProperties:styleDict];
}
// --------------------------------------------------
# pragma mark Font property resolution
// --------------------------------------------------
- (void)_applyFontProperties:(NSDictionary *)dict isLatin:(BOOL)isLatin
{
// font-family
NSString *fontFamily = dict[@"font-family"];
if (fontFamily) {
// Strip quotes, handle generic families (serif, sans-serif, monospace).
fontFamily = [fontFamily stringByTrimmingCharactersInSet:
[NSCharacterSet characterSetWithCharactersInString:@" '\""]];
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
_fontDescriptor.fontFamily = fontFamily;
}
// font-size
NSString *fontSizeStr = dict[@"font-size"];
if (fontSizeStr) {
CGFloat size = [self _floatFromCSSValue:fontSizeStr];
if (size > 0) {
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
_fontDescriptor.pointSize = size;
}
}
// font-weight
NSString *fontWeight = dict[@"font-weight"];
if (fontWeight) {
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
if ([fontWeight isEqualToString:@"bold"] ||
[fontWeight integerValue] >= 700) {
_fontDescriptor.boldTrait = YES;
}
}
// font-style (italic / normal)
NSString *fontStyle = dict[@"font-style"];
if (fontStyle) {
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
if ([fontStyle isEqualToString:@"italic"] ||
[fontStyle isEqualToString:@"oblique"]) {
_fontDescriptor.italicTrait = YES;
}
}
}
// --------------------------------------------------
# pragma mark Text property resolution
// --------------------------------------------------
- (void)_applyTextProperties:(NSDictionary *)dict
{
// text-decoration
NSString *decoration = dict[@"text-decoration"];
if (decoration) {
// underline, line-through, none
// stored for later attributed string construction
}
// text-align
NSString *align = dict[@"text-align"];
if (align) {
_textAlign = align;
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
if ([align isEqualToString:@"center"]) {
_paragraphStyle.textAlignment = kCTCenterTextAlignment;
} else if ([align isEqualToString:@"right"]) {
_paragraphStyle.textAlignment = kCTRightTextAlignment;
} else if ([align isEqualToString:@"justify"]) {
_paragraphStyle.textAlignment = kCTJustifiedTextAlignment;
} else {
_paragraphStyle.textAlignment = kCTLeftTextAlignment;
}
}
// text-indent
NSString *indent = dict[@"text-indent"];
if (indent) {
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
_paragraphStyle.firstLineHeadIndent = [self _floatFromCSSValue:indent];
}
// line-height
NSString *lineHeight = dict[@"line-height"];
if (lineHeight) {
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
_paragraphStyle.lineHeightMultiple = [self _floatFromCSSValue:lineHeight];
}
// letter-spacing
NSString *letterSpacing = dict[@"letter-spacing"];
if (letterSpacing) {
// Applied as kern in the attributed string.
}
// white-space
NSString *ws = dict[@"white-space"];
if (ws) {
_whiteSpace = ws;
}
}
// --------------------------------------------------
# pragma mark Color property resolution
// --------------------------------------------------
- (void)_applyColorProperties:(NSDictionary *)dict
{
// color
NSString *colorStr = dict[@"color"];
if (colorStr) {
_textColor = [self _colorFromCSSValue:colorStr];
}
// background-color
NSString *bgColorStr = dict[@"background-color"];
if (bgColorStr) {
_backgroundColor = [self _colorFromCSSValue:bgColorStr];
}
}
// --------------------------------------------------
# pragma mark Display / layout properties
// --------------------------------------------------
- (void)_applyDisplayProperties:(NSDictionary *)dict
{
NSString *display = dict[@"display"];
if (display) {
if ([display isEqualToString:@"block"] ||
[display isEqualToString:@"flex"] ||
[display isEqualToString:@"grid"]) {
_isBlockElement = YES;
} else if ([display isEqualToString:@"inline"] ||
[display isEqualToString:@"inline-block"]) {
_isBlockElement = NO;
} else if ([display isEqualToString:@"none"]) {
// Element should be hidden; mark for skipping.
}
}
// vertical-align
NSString *vAlign = dict[@"vertical-align"];
if (vAlign) {
// sub, super, top, middle, bottom, etc.
}
}
// --------------------------------------------------
# pragma mark Box model (margin / padding)
// --------------------------------------------------
- (void)_applyBoxModelProperties:(NSDictionary *)dict
{
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
// margin-top
NSString *marginTop = dict[@"margin-top"];
if (marginTop) {
_paragraphStyle.paragraphSpacingBefore = [self _floatFromCSSValue:marginTop];
}
// margin-bottom
NSString *marginBottom = dict[@"margin-bottom"];
if (marginBottom) {
_paragraphStyle.paragraphSpacing = [self _floatFromCSSValue:marginBottom];
}
// padding-left
NSString *paddingLeft = dict[@"padding-left"];
if (paddingLeft) {
_paragraphStyle.headIndent = [self _floatFromCSSValue:paddingLeft];
}
}
// --------------------------------------------------
# pragma mark WeRead-specific CSS properties
// --------------------------------------------------
/**
Handles WeRead's proprietary CSS attributes that control page-level layout.
These are used by WeRead's paging engine (not standard web rendering).
*/
- (void)_applyWeReadProperties:(NSDictionary *)dict
{
// wr-vertical-center-style: vertically center content within a page.
NSString *vCenter = dict[@"wr-vertical-center-style"];
if (vCenter) {
_verticalCenterStyle = vCenter;
}
// weread-page-relate: marks element as related to page-level layout.
NSString *pageRelate = dict[@"weread-page-relate"];
if (pageRelate) {
_pageRelate = pageRelate;
}
// avoidPageBreakInside: prevent page breaks within this element.
NSString *avoidBreak = dict[@"avoidPageBreakInside"];
if (avoidBreak) {
_shouldAvoidPageBreakInside = YES;
}
// DTPageBreakAfter: force a page break after this element.
NSString *breakAfter = dict[@"DTPageBreakAfter"];
if (breakAfter && ([breakAfter boolValue] ||
[breakAfter isEqualToString:@"always"])) {
_pageBreakAfter = YES;
}
// DTPageBreakBefore: force a page break before this element.
NSString *breakBefore = dict[@"DTPageBreakBefore"];
if (breakBefore && ([breakBefore boolValue] ||
[breakBefore isEqualToString:@"always"])) {
_pageBreakBefore = YES;
}
// DTPageBackgroundColor: per-page background color (for styled pages).
NSString *bgColor = dict[@"DTPageBackgroundColor"];
if (bgColor) {
_pageBackgroundColor = [self _colorFromCSSValue:bgColor];
}
// DTPageBackgroundImage: per-page background image URL/path.
NSString *bgImage = dict[@"DTPageBackgroundImage"];
if (bgImage) {
_pageBackgroundImage = bgImage;
}
}
// --------------------------------------------------
# pragma mark Border properties
// --------------------------------------------------
- (void)_applyBorderProperties:(NSDictionary *)dict
{
// border-width, border-style, border-color
NSString *borderWidth = dict[@"border-width"];
NSString *borderStyle = dict[@"border-style"];
NSString *borderColor = dict[@"border-color"];
if (borderWidth || borderStyle || borderColor) {
if (!_borderStyle) {
_borderStyle = [[DTBorderStyle alloc] init];
}
if (borderWidth) {
_borderStyle.borderWidth = [self _floatFromCSSValue:borderWidth];
}
if (borderColor) {
_borderStyle.borderColor = [self _colorFromCSSValue:borderColor];
}
}
}
// --------------------------------------------------
# pragma mark Background properties
// --------------------------------------------------
- (void)_applyBackgroundProperties:(NSDictionary *)dict
{
NSString *bgImage = dict[@"background-image"];
if (bgImage && [bgImage hasPrefix:@"url("]) {
// Extract URL from url('...')
NSRange start = [bgImage rangeOfString:@"'"];
NSRange end = [bgImage rangeOfString:@"'" options:NSBackwardsSearch];
if (start.location != NSNotFound && end.location != NSNotFound) {
NSString *urlStr = [bgImage substringWithRange:
NSMakeRange(start.location + 1,
end.location - start.location - 1)];
if (!_backgroundImageStyle) {
_backgroundImageStyle = [[DTBackgroundImageStyle alloc] init];
}
_backgroundImageStyle.imageURL = [NSURL URLWithString:urlStr];
}
}
}
// --------------------------------------------------
# pragma mark Interpret attributes (finalization)
// --------------------------------------------------
/**
Called after the closing tag is encountered.
Finalizes computed properties that depend on children.
*/
- (void)interpretAttributes
{
// For block elements, ensure a paragraph style exists.
if (_isBlockElement && !_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
// Resolve font descriptor from tag name if not set by CSS.
if (!_fontDescriptor) {
_fontDescriptor = [self _defaultFontDescriptorForTag:_tagName];
}
// Process children (recursive interpret).
for (DTHTMLElement *child in _children) {
[child interpretAttributes];
}
}
/**
Returns a default font descriptor based on the HTML tag name.
*/
- (DTCoreTextFontDescriptor *)_defaultFontDescriptorForTag:(NSString *)tag
{
DTCoreTextFontDescriptor *desc = [[DTCoreTextFontDescriptor alloc] init];
if ([tag isEqualToString:@"b"] || [tag isEqualToString:@"strong"]) {
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"i"] || [tag isEqualToString:@"em"]) {
desc.italicTrait = YES;
}
else if ([tag isEqualToString:@"big"]) {
desc.pointSize = 18.0;
}
else if ([tag isEqualToString:@"small"]) {
desc.pointSize = 10.0;
}
else if ([tag isEqualToString:@"h1"]) {
desc.pointSize = 24.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"h2"]) {
desc.pointSize = 20.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"h3"]) {
desc.pointSize = 16.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"h4"]) {
desc.pointSize = 14.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"code"] ||
[tag isEqualToString:@"tt"] ||
[tag isEqualToString:@"pre"]) {
desc.monospaceFamily = YES;
}
return desc;
}
// --------------------------------------------------
# pragma mark Attributed string generation
// --------------------------------------------------
/**
Recursively converts this element and all its children into an
NSAttributedString. This is the core rendering method.
Algorithm:
1. Create a mutable attributed string from this element's text content.
2. Apply font, color, paragraph style, and link attributes.
3. For each child, call -attributedString recursively and append.
4. Handle special elements (img, br, table, etc.).
5. Return the assembled string.
*/
- (NSAttributedString *)attributedString
{
NSMutableAttributedString *output = [[NSMutableAttributedString alloc] init];
// Step 1: Handle void elements first.
if ([self isVoidElement]) {
return [self _attributedStringForVoidElement];
}
// Step 2: Emit page break before marker if needed.
if (_pageBreakBefore) {
NSDictionary *breakAttrs = @{
DTPageBreakBeforeAttribute: @YES
};
NSAttributedString *breakStr = [[NSAttributedString alloc]
initWithString:@"" // LINE SEPARATOR as page break marker
attributes:breakAttrs];
[output appendAttributedString:breakStr];
}
// Step 3: Process text content.
if (_text && _text.length > 0) {
NSAttributedString *textStr = [self _attributedStringForText:_text];
[output appendAttributedString:textStr];
}
// Step 4: Process children recursively.
for (DTHTMLElement *child in _children) {
NSAttributedString *childStr = [child attributedString];
if (childStr) {
[output appendAttributedString:childStr];
}
}
// Step 5: Wrap in paragraph style if this is a block element.
if (_isBlockElement && output.length > 0) {
[self _applyParagraphStyleToString:output];
}
// Step 6: Emit page break after marker if needed.
if (_pageBreakAfter) {
NSDictionary *breakAttrs = @{
DTPageBreakAfterAttribute: @YES
};
NSAttributedString *breakStr = [[NSAttributedString alloc]
initWithString:@""
attributes:breakAttrs];
[output appendAttributedString:breakStr];
}
// Step 7: Apply WeRead page-level attributes.
[self _applyWeReadPageAttributesToString:output];
return output;
}
// --------------------------------------------------
# pragma mark Text → NSAttributedString
// --------------------------------------------------
/**
Creates an NSAttributedString from text with the element's resolved styles.
*/
- (NSAttributedString *)_attributedStringForText:(NSString *)text
{
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
// Font
CTFontRef font = [_fontDescriptor newMatchingFont];
if (font) {
attrs[(id)kCTFontAttributeName] = (__bridge id)font;
CFRelease(font);
}
// Text color
if (_textColor) {
attrs[(id)kCTForegroundColorAttributeName] = (__bridge id)_textColor.CGColor;
}
// Background color (highlight)
if (_backgroundColor) {
attrs[@"DTBackgroundColor"] = _backgroundColor;
}
// Link
if (_linkURL) {
attrs[@"DTLink"] = _linkURL;
}
// Kern (letter-spacing)
// if (_letterSpacing) { attrs[(id)kCTKernAttributeName] = ...; }
return [[NSAttributedString alloc] initWithString:text attributes:attrs];
}
// --------------------------------------------------
# pragma mark Void element handling
// --------------------------------------------------
- (BOOL)isVoidElement
{
return [_voidElements containsObject:_tagName];
}
/**
Produces the attributed string for void elements (br, img, hr, etc.)
*/
- (NSAttributedString *)_attributedStringForVoidElement
{
if ([_tagName isEqualToString:@"br"]) {
// Line break: insert newline with current paragraph style.
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
if (_paragraphStyle) {
attrs[@"DTParagraphStyle"] = _paragraphStyle;
}
return [[NSAttributedString alloc] initWithString:@"\n"
attributes:attrs];
}
else if ([_tagName isEqualToString:@"img"]) {
// Image: create a text attachment and wrap in attributed string.
return [self _attributedStringForImage];
}
else if ([_tagName isEqualToString:@"hr"]) {
// Horizontal rule: treated as a paragraph separator.
return [[NSAttributedString alloc] initWithString:@"\n"];
}
return [[NSAttributedString alloc] initWithString:@""];
}
/**
Creates an NSAttributedString containing an image attachment.
*/
- (NSAttributedString *)_attributedStringForImage
{
if (!_textAttachment) {
_textAttachment = [[DTTextAttachment alloc] init];
_textAttachment.contentURL = _imageURL;
}
// The attachment is represented by the Unicode object replacement character.
unichar objectChar = 0xFFFC;
NSString *objectStr = [NSString stringWithCharacters:&objectChar length:1];
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
attrs[@"DTTextAttachment"] = _textAttachment;
if (_linkURL) {
attrs[@"DTLink"] = _linkURL;
}
return [[NSAttributedString alloc] initWithString:objectStr
attributes:attrs];
}
// --------------------------------------------------
# pragma mark Paragraph style application
// --------------------------------------------------
/**
Applies the element's paragraph style to the entire range of the string.
*/
- (void)_applyParagraphStyleToString:(NSMutableAttributedString *)str
{
if (!_paragraphStyle || str.length == 0) return;
CTParagraphStyleRef ctStyle = [_paragraphStyle createCTParagraphStyle];
if (ctStyle) {
[str addAttribute:(id)kCTParagraphStyleAttributeName
value:(__bridge id)ctStyle
range:NSMakeRange(0, str.length)];
CFRelease(ctStyle);
}
}
// --------------------------------------------------
# pragma mark WeRead page attribute application
// --------------------------------------------------
/**
Applies WeRead-specific page layout attributes to the string.
These attributes are consumed by WeRead's paging engine to control
page breaks, backgrounds, and vertical centering.
*/
- (void)_applyWeReadPageAttributesToString:(NSMutableAttributedString *)str
{
if (str.length == 0) return;
NSRange fullRange = NSMakeRange(0, str.length);
// Vertical center style
if (_verticalCenterStyle) {
[str addAttribute:DTHTMLVerticalCenterAttribute
value:_verticalCenterStyle
range:fullRange];
}
// Page relate
if (_pageRelate) {
[str addAttribute:DTPageRelateAttribute
value:_pageRelate
range:fullRange];
}
// Avoid page break inside
if (_shouldAvoidPageBreakInside) {
[str addAttribute:DTPageBreakInsideAvoidAttribute
value:@YES
range:fullRange];
}
// Page background color
if (_pageBackgroundColor) {
[str addAttribute:DTPageBackgroundColorAttribute
value:_pageBackgroundColor
range:fullRange];
}
// Page background image
if (_pageBackgroundImage) {
[str addAttribute:DTPageBackgroundImageAttribute
value:_pageBackgroundImage
range:fullRange];
}
}
// --------------------------------------------------
# pragma mark CSS value parsing helpers
// --------------------------------------------------
/**
Converts a CSS length value string (e.g. "14px", "1.2em", "100%")
into a CGFloat in points.
*/
- (CGFloat)_floatFromCSSValue:(NSString *)value
{
if (!value || value.length == 0) return 0.0;
// Strip whitespace.
value = [value stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
// Handle special values.
if ([value isEqualToString:@"inherit"] ||
[value isEqualToString:@"auto"]) {
return 0.0;
}
// Extract numeric part.
NSString *numericPart = value;
NSString *unit = @"";
// Check for known units.
NSArray *units = @[@"px", @"em", @"rem", @"pt", @"%", @"ex"];
for (NSString *u in units) {
if ([value hasSuffix:u]) {
numericPart = [value substringToIndex:value.length - u.length];
unit = u;
break;
}
}
CGFloat floatValue = [numericPart floatValue];
// Convert to points (simplified; real implementation handles em/% relative
// to parent).
if ([unit isEqualToString:@"em"] || [unit isEqualToString:@"rem"]) {
// Assume 1em = parent font size (default 16px).
floatValue *= 16.0;
} else if ([unit isEqualToString:@"pt"]) {
// 1pt = 1pt (no conversion needed on iOS).
} else if ([unit isEqualToString:@"%"]) {
// Percentage: caller must interpret relative to container.
}
// "px" → points (1:1 on non-retina, but iOS uses points natively).
return floatValue;
}
/**
Converts a CSS color value string to UIColor.
Supports:
- #RRGGBB hex notation
- #RGB shorthand
- rgb(r,g,b) functional notation
- Named colors (red, blue, etc.)
*/
- (UIColor *)_colorFromCSSValue:(NSString *)cssValue
{
if (!cssValue || cssValue.length == 0) return nil;
cssValue = [cssValue stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
// Hex colors
if ([cssValue hasPrefix:@"#"]) {
NSString *hex = [cssValue substringFromIndex:1];
// Expand shorthand #RGB → #RRGGBB
if (hex.length == 3) {
hex = [NSString stringWithFormat:@"%C%C%C%C%C%C",
[hex characterAtIndex:0], [hex characterAtIndex:0],
[hex characterAtIndex:1], [hex characterAtIndex:1],
[hex characterAtIndex:2], [hex characterAtIndex:2]];
}
if (hex.length == 6) {
unsigned int rgb = 0;
[[NSScanner scannerWithString:hex] scanHexInt:&rgb];
return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0
green:((rgb >> 8) & 0xFF) / 255.0
blue:((rgb >> 0) & 0xFF) / 255.0
alpha:1.0];
}
}
// rgb(r,g,b) notation
if ([cssValue hasPrefix:@"rgb("] || [cssValue hasPrefix:@"rgba("]) {
NSString *inner = cssValue;
inner = [inner stringByReplacingOccurrencesOfString:@"rgb(" withString:@""];
inner = [inner stringByReplacingOccurrencesOfString:@"rgba(" withString:@""];
inner = [inner stringByReplacingOccurrencesOfString:@")" withString:@""];
NSArray *components = [inner componentsSeparatedByString:@","];
if (components.count >= 3) {
CGFloat r = [components[0] floatValue] / 255.0;
CGFloat g = [components[1] floatValue] / 255.0;
CGFloat b = [components[2] floatValue] / 255.0;
CGFloat a = components.count >= 4 ? [components[3] floatValue] : 1.0;
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}
}
// Named colors
static NSDictionary *namedColors = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
namedColors = @{
@"black" : [UIColor blackColor],
@"white" : [UIColor whiteColor],
@"red" : [UIColor redColor],
@"green" : [UIColor greenColor],
@"blue" : [UIColor blueColor],
@"yellow" : [UIColor yellowColor],
@"gray" : [UIColor grayColor],
@"grey" : [UIColor grayColor],
@"cyan" : [UIColor cyanColor],
@"magenta" : [UIColor magentaColor],
@"orange" : [UIColor orangeColor],
@"purple" : [UIColor purpleColor],
@"clear" : [UIColor clearColor],
};
});
UIColor *named = namedColors[cssValue.lowercaseString];
if (named) return named;
return nil;
}
@end
+111
View File
@@ -0,0 +1,111 @@
//
// WRBookmark.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Bookmark model. Stores bookId, chapterUid, and position information.
// Supports highlights, underlines, and page marks with associated text.
// Synced with server via WRBookNetwork.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WRMPReview;
// ---------------------------------------------------------------------------
// Bookmark types
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRBookmarkType) {
WRBookmarkTypeHighlight = 0, // Yellow/blue/etc highlight
WRBookmarkTypeUnderline = 1, // Underline annotation
WRBookmarkTypeMark = 2, // Page bookmark (dog-ear)
WRBookmarkTypeNote = 3, // Written note
WRBookmarkTypePencil = 4, // Apple Pencil drawing
};
// ---------------------------------------------------------------------------
// WRBookmark
// ---------------------------------------------------------------------------
@interface WRBookmark : NSObject
// --- Ivars (from binary analysis) ---
// NSString (many): bookId, chapterUid, markText, colorStyle, reviewId,
// anchorId, rangeKey, noteContent, etc.
// WRBook: associated book model
// WRMPReview: associated review/comment model
// NSArray: range info, selected text fragments
// NSDictionary: extra metadata
// NSMutableSet: tags
// NSDictionary: sync metadata (syncKey, serverVersion)
@property (nonatomic, copy) NSString *bookmarkId; // unique bookmark ID
@property (nonatomic, copy) NSString *bookId; // book identifier
@property (nonatomic, copy) NSString *chapterUid; // chapter UID
@property (nonatomic, assign) NSInteger chapterOffset; // character offset within chapter
@property (nonatomic, assign) NSInteger chapterIndex; // chapter index in spine
@property (nonatomic, copy) NSString *markText; // highlighted/marked text
@property (nonatomic, copy, nullable) NSString *noteContent; // user note text
@property (nonatomic, assign) WRBookmarkType type; // bookmark type
@property (nonatomic, copy, nullable) NSString *colorStyle; // highlight color (e.g., "yellow", "blue", "red", "green")
@property (nonatomic, assign) NSInteger startPos; // start position (global)
@property (nonatomic, assign) NSInteger endPos; // end position (global)
@property (nonatomic, assign) NSInteger rangeLength; // length of the range
@property (nonatomic, copy, nullable) NSString *anchorId; // DOM anchor ID
@property (nonatomic, copy, nullable) NSString *rangeKey; // range key for sync
@property (nonatomic, strong, nullable) WRBook *book; // associated book
@property (nonatomic, strong, nullable) WRMPReview *review; // associated review
@property (nonatomic, strong, nullable) NSArray *rangeInfo; // detailed range info
@property (nonatomic, strong, nullable) NSDictionary *extraMetadata; // additional data
@property (nonatomic, strong, nullable) NSMutableSet<NSString *> *tags;
@property (nonatomic, assign) NSTimeInterval createTime; // creation timestamp
@property (nonatomic, assign) NSTimeInterval updateTime; // last update timestamp
@property (nonatomic, assign) BOOL isSynced; // synced with server
@property (nonatomic, copy, nullable) NSString *syncKey; // sync key for incremental updates
#pragma mark - Factory Methods
+ (instancetype)bookmarkWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
offset:(NSInteger)offset
text:(NSString *)text
type:(WRBookmarkType)type;
+ (instancetype)highlightWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
colorStyle:(NSString *)colorStyle;
#pragma mark - Serialization
- (NSDictionary *)toDictionary;
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
/// Convert to JSON data for network sync.
- (nullable NSData *)toJSONData;
/// Create from JSON data received from server.
+ (nullable instancetype)fromJSONData:(NSData *)data;
#pragma mark - Display
/// Return a display-friendly summary string.
- (NSString *)displaySummary;
/// Return the color as a UIColor.
- (UIColor *)highlightUIColor;
@end
NS_ASSUME_NONNULL_END
+219
View File
@@ -0,0 +1,219 @@
//
// WRBookmark.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis (many NSString ivars,
// WRBook, WRMPReview, NSArray, NSDictionary, NSMutableSet) and
// contextual knowledge of WeRead's bookmark/annotation system.
//
#import "WRBookmark.h"
// ---------------------------------------------------------------------------
// Color mapping
// ---------------------------------------------------------------------------
static NSDictionary<NSString *, UIColor *> *sColorMap = nil;
@implementation WRBookmark
#pragma mark - Class Initialization
+ (void)initialize
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sColorMap = @{
@"yellow" : [UIColor colorWithRed:1.0 green:0.9 blue:0.3 alpha:0.4],
@"blue" : [UIColor colorWithRed:0.3 green:0.6 blue:1.0 alpha:0.4],
@"red" : [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.4],
@"green" : [UIColor colorWithRed:0.3 green:0.9 blue:0.4 alpha:0.4],
@"purple" : [UIColor colorWithRed:0.7 green:0.3 blue:0.9 alpha:0.4],
};
});
}
#pragma mark - Factory Methods
+ (instancetype)bookmarkWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
offset:(NSInteger)offset
text:(NSString *)text
type:(WRBookmarkType)type
{
WRBookmark *bm = [[WRBookmark alloc] init];
bm.bookId = bookId;
bm.chapterUid = chapterUid;
bm.chapterOffset = offset;
bm.markText = text;
bm.type = type;
bm.createTime = [[NSDate date] timeIntervalSince1970];
bm.updateTime = bm.createTime;
bm.isSynced = NO;
// Generate a unique bookmark ID
bm.bookmarkId = [[NSUUID UUID] UUIDString];
return bm;
}
+ (instancetype)highlightWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
colorStyle:(NSString *)colorStyle
{
WRBookmark *bm = [self bookmarkWithBookId:bookId
chapterUid:chapterUid
offset:startPos
text:text
type:WRBookmarkTypeHighlight];
bm.startPos = startPos;
bm.endPos = endPos;
bm.rangeLength = endPos - startPos;
bm.colorStyle = colorStyle ?: @"yellow";
return bm;
}
#pragma mark - Serialization
- (NSDictionary *)toDictionary
{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
if (_bookmarkId) dict[@"bookmarkId"] = _bookmarkId;
if (_bookId) dict[@"bookId"] = _bookId;
if (_chapterUid) dict[@"chapterUid"] = _chapterUid;
dict[@"chapterOffset"] = @(_chapterOffset);
dict[@"chapterIndex"] = @(_chapterIndex);
if (_markText) dict[@"markText"] = _markText;
if (_noteContent) dict[@"noteContent"] = _noteContent;
dict[@"type"] = @(_type);
if (_colorStyle) dict[@"colorStyle"] = _colorStyle;
dict[@"startPos"] = @(_startPos);
dict[@"endPos"] = @(_endPos);
dict[@"rangeLength"] = @(_rangeLength);
if (_anchorId) dict[@"anchorId"] = _anchorId;
if (_rangeKey) dict[@"rangeKey"] = _rangeKey;
dict[@"createTime"] = @(_createTime);
dict[@"updateTime"] = @(_updateTime);
dict[@"isSynced"] = @(_isSynced);
if (_syncKey) dict[@"syncKey"] = _syncKey;
if (_rangeInfo) dict[@"rangeInfo"] = _rangeInfo;
if (_extraMetadata) dict[@"extraMetadata"] = _extraMetadata;
if (_tags.count > 0) {
dict[@"tags"] = [_tags allObjects];
}
return [dict copy];
}
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
{
if (!dict) return nil;
WRBookmark *bm = [[WRBookmark alloc] init];
bm.bookmarkId = dict[@"bookmarkId"];
bm.bookId = dict[@"bookId"];
bm.chapterUid = dict[@"chapterUid"];
bm.chapterOffset = [dict[@"chapterOffset"] integerValue];
bm.chapterIndex = [dict[@"chapterIndex"] integerValue];
bm.markText = dict[@"markText"];
bm.noteContent = dict[@"noteContent"];
bm.type = [dict[@"type"] integerValue];
bm.colorStyle = dict[@"colorStyle"];
bm.startPos = [dict[@"startPos"] integerValue];
bm.endPos = [dict[@"endPos"] integerValue];
bm.rangeLength = [dict[@"rangeLength"] integerValue];
bm.anchorId = dict[@"anchorId"];
bm.rangeKey = dict[@"rangeKey"];
bm.createTime = [dict[@"createTime"] doubleValue];
bm.updateTime = [dict[@"updateTime"] doubleValue];
bm.isSynced = [dict[@"isSynced"] boolValue];
bm.syncKey = dict[@"syncKey"];
bm.rangeInfo = dict[@"rangeInfo"];
bm.extraMetadata = dict[@"extraMetadata"];
NSArray *tags = dict[@"tags"];
if (tags) {
bm.tags = [NSMutableSet setWithArray:tags];
}
return bm;
}
- (nullable NSData *)toJSONData
{
NSDictionary *dict = [self toDictionary];
return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
}
+ (nullable instancetype)fromJSONData:(NSData *)data
{
if (!data) return nil;
NSError *error = nil;
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
options:0
error:&error];
if (!dict || ![dict isKindOfClass:[NSDictionary class]]) return nil;
return [self fromDictionary:dict];
}
#pragma mark - Display
- (NSString *)displaySummary
{
switch (_type) {
case WRBookmarkTypeHighlight:
return [NSString stringWithFormat:@"[Highlight] %@", _markText ?: @""];
case WRBookmarkTypeUnderline:
return [NSString stringWithFormat:@"[Underline] %@", _markText ?: @""];
case WRBookmarkTypeMark:
return [NSString stringWithFormat:@"[Bookmark] Chapter %@", _chapterUid ?: @""];
case WRBookmarkTypeNote:
return [NSString stringWithFormat:@"[Note] %@", _noteContent ?: _markText ?: @""];
case WRBookmarkTypePencil:
return @"[Pencil Note]";
default:
return _markText ?: @"";
}
}
- (UIColor *)highlightUIColor
{
if (!_colorStyle) {
return sColorMap[@"yellow"] ?: [UIColor yellowColor];
}
return sColorMap[_colorStyle] ?: [UIColor yellowColor];
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:@"<WRBookmark: %@ type=%ld book=%@ ch=%@ '%@'>",
_bookmarkId, (long)_type, _bookId, _chapterUid,
[_markText substringToIndex:MIN(30, _markText.length)]];
}
- (BOOL)isEqual:(id)object
{
if (self == object) return YES;
if (![object isKindOfClass:[WRBookmark class]]) return NO;
WRBookmark *other = (WRBookmark *)object;
return [self.bookmarkId isEqualToString:other.bookmarkId];
}
- (NSUInteger)hash
{
return self.bookmarkId.hash;
}
@end
+183
View File
@@ -0,0 +1,183 @@
//
// WRChapterData.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Chapter data model. Stores the typeset NSAttributedString.
// Manages highlights, underlines, reviews/annotations.
// Uses WRCoreTextLayouter for layout computation.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
@class WRCoreTextLayouter;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - Highlight / Underline Style Constants
// ============================================================================
/// Style of underline drawn for highlights and annotations.
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
WRUnderlineStyleNone = 0,
WRUnderlineStyleSolid = 1,
WRUnderlineStyleDashed = 2,
WRUnderlineStyleWavy = 3,
};
/// Type of review / annotation.
typedef NS_ENUM(NSInteger, WRReviewType) {
WRReviewTypeHighlight = 0, // Color highlight
WRReviewTypeUnderline = 1, // Underline only
WRReviewTypeNote = 2, // Text note attached to range
};
// ============================================================================
#pragma mark - WRChapterData
// ============================================================================
@interface WRChapterData : NSObject
// ---- Core content ----
/// The fully typeset attributed string for this chapter, with all fonts,
/// colors, paragraph styles, and inline image attachments applied.
@property (nonatomic, strong, nullable) NSMutableAttributedString *typesetAttributedString;
/// The layouter that computes line breaks and page breaks for this chapter.
@property (nonatomic, strong, nullable) WRCoreTextLayouter *layouter;
// ---- Page ranges ----
/// Array of NSValue-wrapped NSRange values, one per page.
/// Each range is a character range within typesetAttributedString.
@property (nonatomic, strong, nullable) NSArray<NSValue *> *pageRanges;
// ---- Highlights and annotations ----
/// Array of highlight dictionaries. Each entry contains:
/// @"range" : NSValue wrapping NSRange
/// @"key" : NSString (unique highlight ID)
/// @"itemId" : NSString (item identifier, e.g., bookmark ID)
/// @"color" : UIColor
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *highlights;
/// Array of underline / review dictionaries. Each entry contains:
/// @"range" : NSValue wrapping NSRange
/// @"itemId" : NSString
/// @"type" : @(WRReviewType)
/// @"style" : @(WRUnderlineStyle)
/// @"color" : UIColor
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *underlines;
/// Temporary review highlight (not yet saved), used during review creation.
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *tempReviewHighlights;
/// Set of bookmarked page indices (NSSet of NSNumber).
@property (nonatomic, strong, nullable) NSSet<NSNumber *> *bookmarkedPages;
/// Raw chapter source text (before typesetting).
@property (nonatomic, strong, nullable) NSAttributedString *sourceAttributedString;
// ---- Chapter metadata ----
@property (nonatomic, copy, nullable) NSString *chapterId;
@property (nonatomic, copy, nullable) NSString *chapterTitle;
@property (nonatomic, assign) NSUInteger chapterIndex;
// ---- Content insets for the reading area ----
@property (nonatomic, assign) UIEdgeInsets contentInsets;
// ---- Outline / TOC ----
/// Array of outline entry dictionaries generated from headings in the chapter.
/// Each entry: @"title", @"level", @"range" (NSValue wrapping NSRange).
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *outlineContents;
// ---- Free trial cutoff ----
/// The string location (character index) at which the free trial ends.
/// NSNotFound if the chapter is fully accessible.
@property (nonatomic, assign) NSUInteger freeTrialCutOffLocation;
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
/// Adds an underline decoration to the given attributed string at the specified
/// range, with the given style, color, and associated item ID.
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString
range:(NSRange)range
itemId:(NSString *)itemId
style:(WRUnderlineStyle)style
color:(UIColor *)color;
/// Calculates the free trial cutoff string location within the attributed
/// string for the given book. Returns the character index where content
/// should be truncated for non-paying users.
+ (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString
book:(id)book;
// ============================================================================
#pragma mark - Instance Methods — Highlights & Underlines
// ============================================================================
/// Adds an auto-read underline (visual indicator for auto-scroll mode).
- (void)addAutoReadUnderLineInRange:(NSRange)range
style:(WRUnderlineStyle)style
color:(UIColor *)color;
/// Adds a highlight annotation.
- (void)addHighlightInRange:(NSRange)range
key:(NSString *)key
itemId:(NSString *)itemId
color:(UIColor *)color;
/// Adds a review underline (e.g., from a friend's review).
- (void)addReviewUnderlineInRange:(NSRange)range
itemId:(NSString *)itemId
type:(WRReviewType)type;
/// Adds a temporary review highlight (not persisted until confirmed).
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId;
/// Adds a temporary review highlight with a custom color.
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId
color:(UIColor *)color;
/// Removes a review underline by range and type.
- (void)deleteReviewUnderlineInRange:(NSRange)range
type:(WRReviewType)type;
// ============================================================================
#pragma mark - Instance Methods — Page Queries
// ============================================================================
/// Returns the character range within typesetAttributedString for the given
/// page index (0-based). Uses the pageRanges array computed during layout.
- (NSRange)rangeOfPage:(NSUInteger)pageIndex;
// ============================================================================
#pragma mark - Instance Methods — Outline
// ============================================================================
/// Scans the attributed string for heading styles and builds the
/// outlineContents array.
- (void)generateOutlineContents;
// ============================================================================
#pragma mark - Instance Methods — Free Trial
// ============================================================================
/// Returns the real (post-typeset) string location for the free trial cutoff.
- (NSUInteger)freeTrialChapterCutOffRealStringLocation;
/// Sets the free trial cutoff string location.
- (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location;
@end
NS_ASSUME_NONNULL_END
+442
View File
@@ -0,0 +1,442 @@
//
// WRChapterData.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the chapter data model.
// Stores the typeset NSAttributedString, manages highlights, underlines,
// reviews, page ranges, and outline generation.
//
#import "WRChapterData.h"
#import "WRCoreTextLayouter.h"
// ============================================================================
#pragma mark - Constants
// ============================================================================
static NSString *const kHighlightRangeKey = @"range";
static NSString *const kHighlightKeyKey = @"key";
static NSString *const kHighlightItemIdKey = @"itemId";
static NSString *const kHighlightColorKey = @"color";
static NSString *const kUnderlineStyleKey = @"style";
static NSString *const kUnderlineTypeKey = @"type";
// Custom attribute name used in the attributed string to mark underlines.
static NSString *const kWRUnderlineAttributeName =
@"com.weread.underline";
// Custom attribute name for highlight color.
static NSString *const kWRHighlightAttributeName =
@"com.weread.highlight";
// ============================================================================
#pragma mark - WRChapterData ()
// ============================================================================
@interface WRChapterData ()
/// Internal mutable copy of highlights for mutation.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableHighlights;
/// Internal mutable copy of underlines.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableUnderlines;
/// Internal mutable copy of temp review highlights.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableTempReviewHighlights;
@end
// ============================================================================
#pragma mark - WRChapterData Implementation
// ============================================================================
@implementation WRChapterData
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)init {
self = [super init];
if (self) {
_mutableHighlights = [NSMutableArray array];
_mutableUnderlines = [NSMutableArray array];
_mutableTempReviewHighlights = [NSMutableArray array];
_bookmarkedPages = [NSSet set];
_freeTrialCutOffLocation = NSNotFound;
_contentInsets = UIEdgeInsetsMake(20.0, 16.0, 20.0, 16.0);
}
return self;
}
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
///
/// Adds an underline decoration to an NSMutableAttributedString at the
/// specified range. The underline is stored as a custom attribute so the
/// drawing code can render it with the correct style and color.
///
/// @param attributedString The mutable attributed string to modify.
/// @param range The character range to underline.
/// @param itemId Identifier for the item (e.g., bookmark or review ID).
/// @param style The underline style (solid, dashed, wavy).
/// @param color The underline color.
///
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString
range:(NSRange)range
itemId:(NSString *)itemId
style:(WRUnderlineStyle)style
color:(UIColor *)color {
if (!attributedString || range.location == NSNotFound) return;
if (NSMaxRange(range) > attributedString.length) return;
// Build the underline descriptor dictionary.
NSDictionary *underlineInfo = @{
kHighlightItemIdKey : itemId ?: @"",
kUnderlineStyleKey : @(style),
kHighlightColorKey : color ?: [UIColor blackColor],
};
// Apply as a custom attribute. The rendering code in WRCoreTextLayoutFrame
// will read this attribute and draw the underline during -drawInContext:.
[attributedString addAttribute:kWRUnderlineAttributeName
value:underlineInfo
range:range];
}
///
/// Calculates the free trial cutoff location within the attributed string.
/// The book object provides trial chapter limits; this method finds the
/// corresponding character position in the typeset string.
///
/// @param attributedString The typeset attributed string.
/// @param book The book model object (provides trial info).
/// @return The character index at which to cut off, or NSNotFound if fully accessible.
///
+ (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString
book:(id)book {
if (!attributedString || !book) return NSNotFound;
// In the real implementation, this queries the book model for:
// - The number of free trial characters / chapters allowed.
// - Whether this specific chapter falls within the trial range.
// It then maps that to a character index in the attributed string.
//
// Typical logic:
// 1. Ask `book` for the trial character count or chapter index limit.
// 2. If this chapter is entirely within the trial, return NSNotFound (no cutoff).
// 3. If this chapter is entirely beyond the trial, return 0 (show nothing).
// 4. If the cutoff falls within this chapter, calculate the offset.
// Placeholder: assume the book responds to -freeTrialCharacterLimit.
if ([book respondsToSelector:NSSelectorFromString(@"freeTrialCharacterLimit")]) {
NSUInteger limit = [[book valueForKey:@"freeTrialCharacterLimit"] unsignedIntegerValue];
NSUInteger totalLength = attributedString.length;
if (limit >= totalLength) {
// Entire chapter is accessible.
return NSNotFound;
} else if (limit == 0) {
// No access.
return 0;
} else {
return limit;
}
}
return NSNotFound;
}
// ============================================================================
#pragma mark - Highlight & Underline Management
// ============================================================================
/// Adds an auto-read underline. This is a visual indicator showing which
/// text is being auto-scrolled through.
- (void)addAutoReadUnderLineInRange:(NSRange)range
style:(WRUnderlineStyle)style
color:(UIColor *)color {
if (range.location == NSNotFound) return;
// Apply the underline attribute to the typeset string.
[WRChapterData addUnderLineToAttributedString:self.typesetAttributedString
range:range
itemId:@"autoRead"
style:style
color:color];
}
/// Adds a persistent highlight annotation.
- (void)addHighlightInRange:(NSRange)range
key:(NSString *)key
itemId:(NSString *)itemId
color:(UIColor *)color {
if (range.location == NSNotFound) return;
NSDictionary *entry = @{
kHighlightRangeKey : [NSValue valueWithRange:range],
kHighlightKeyKey : key ?: @"",
kHighlightItemIdKey : itemId ?: @"",
kHighlightColorKey : color ?: [UIColor yellowColor],
};
[_mutableHighlights addObject:entry];
_highlights = [_mutableHighlights copy];
// Also apply the highlight as a custom attribute on the attributed string
// so the CoreText drawing code can render the background color.
[self.typesetAttributedString addAttribute:kWRHighlightAttributeName
value:@{kHighlightColorKey: (color ?: [UIColor yellowColor])}
range:range];
}
/// Adds a review underline (from a friend's annotation or review).
- (void)addReviewUnderlineInRange:(NSRange)range
itemId:(NSString *)itemId
type:(WRReviewType)type {
if (range.location == NSNotFound) return;
NSDictionary *entry = @{
kHighlightRangeKey : [NSValue valueWithRange:range],
kHighlightItemIdKey : itemId ?: @"",
kUnderlineTypeKey : @(type),
};
[_mutableUnderlines addObject:entry];
_underlines = [_mutableUnderlines copy];
// Apply underline attribute for rendering.
WRUnderlineStyle style = (type == WRReviewTypeHighlight)
? WRUnderlineStyleNone
: WRUnderlineStyleSolid;
UIColor *color = (type == WRReviewTypeHighlight)
? [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:0.3]
: [UIColor colorWithRed:1.0 green:0.4 blue:0.4 alpha:0.8];
if (style != WRUnderlineStyleNone) {
[WRChapterData addUnderLineToAttributedString:self.typesetAttributedString
range:range
itemId:itemId
style:style
color:color];
}
}
/// Adds a temporary review highlight (used during the review creation flow
/// before the user confirms and persists it).
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId {
[self addTempReviewHighlightInRange:range
itemId:itemId
color:[UIColor colorWithRed:0.2
green:0.6
blue:1.0
alpha:0.3]];
}
/// Adds a temporary review highlight with a custom color.
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId
color:(UIColor *)color {
if (range.location == NSNotFound) return;
NSDictionary *entry = @{
kHighlightRangeKey : [NSValue valueWithRange:range],
kHighlightItemIdKey : itemId ?: @"",
kHighlightColorKey : color ?: [UIColor yellowColor],
};
[_mutableTempReviewHighlights addObject:entry];
_tempReviewHighlights = [_mutableTempReviewHighlights copy];
// Apply as a temporary attribute (not persisted).
[self.typesetAttributedString addAttribute:kWRHighlightAttributeName
value:@{kHighlightColorKey: (color ?: [UIColor yellowColor]),
@"temporary": @YES}
range:range];
}
/// Removes a review underline from both the internal array and the
/// attributed string attributes.
- (void)deleteReviewUnderlineInRange:(NSRange)range
type:(WRReviewType)type {
if (range.location == NSNotFound) return;
// Remove matching entries from the mutable array.
NSMutableArray *toRemove = [NSMutableArray array];
for (NSDictionary *entry in _mutableUnderlines) {
NSRange entryRange = [entry[kHighlightRangeKey] rangeValue];
WRReviewType entryType = [entry[kUnderlineTypeKey] integerValue];
if (NSEqualRanges(entryRange, range) && entryType == type) {
[toRemove addObject:entry];
}
}
[_mutableUnderlines removeObjectsInArray:toRemove];
_underlines = [_mutableUnderlines copy];
// Remove the custom underline/highlight attributes from the string.
[self.typesetAttributedString removeAttribute:kWRUnderlineAttributeName range:range];
[self.typesetAttributedString removeAttribute:kWRHighlightAttributeName range:range];
}
// ============================================================================
#pragma mark - Page Range Queries
// ============================================================================
/// Returns the character range for the given page index.
/// The pageRanges array is computed during layout by WRCoreTextLayouter
/// and stored as an array of NSValue-wrapped NSRange objects.
- (NSRange)rangeOfPage:(NSUInteger)pageIndex {
if (pageIndex >= self.pageRanges.count) {
return NSMakeRange(NSNotFound, 0);
}
return [self.pageRanges[pageIndex] rangeValue];
}
// ============================================================================
#pragma mark - Outline Generation
// ============================================================================
/// Scans the typeset attributed string for heading-level paragraph styles
/// and builds an array of outline entries. Each entry is a dictionary with:
/// @"title" : NSString (the heading text)
/// @"level" : NSNumber (1 for H1, 2 for H2, etc.)
/// @"range" : NSValue wrapping the NSRange in the attributed string.
- (void)generateOutlineContents {
NSMutableArray<NSDictionary *> *outline = [NSMutableArray array];
NSAttributedString *str = self.typesetAttributedString;
if (!str || str.length == 0) {
self.outlineContents = @[];
return;
}
// Walk the attributed string by paragraph.
NSString *plainText = [str string];
NSUInteger length = plainText.length;
NSUInteger searchLoc = 0;
while (searchLoc < length) {
// Find the paragraph range.
NSRange paraRange = [plainText rangeOfString:@"\n"
options:0
range:NSMakeRange(searchLoc, length - searchLoc)];
if (paraRange.location == NSNotFound) {
paraRange = NSMakeRange(searchLoc, length - searchLoc);
} else {
paraRange = NSMakeRange(searchLoc, paraRange.location - searchLoc + 1);
}
// Check if this paragraph has a heading font attribute.
if (paraRange.length > 0) {
NSDictionary *attrs = [str attributesAtIndex:paraRange.location
effectiveRange:NULL];
UIFont *font = attrs[NSFontAttributeName];
// Heuristic: heading fonts are typically larger than body text.
// In WeRead, headings may use a custom attribute or a specific
// font descriptor. We check for font size > bodySize + 4.
CGFloat bodySize = 16.0; // Typical body font size.
if (font && font.pointSize > bodySize + 4.0) {
NSString *title = [plainText substringWithRange:
NSMakeRange(paraRange.location,
paraRange.length > 1 ? paraRange.length - 1 : paraRange.length)];
title = [title stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (title.length > 0) {
NSInteger level = 1;
if (font.pointSize < bodySize + 8.0) level = 2;
if (font.pointSize < bodySize + 6.0) level = 3;
[outline addObject:@{
@"title": title,
@"level": @(level),
@"range": [NSValue valueWithRange:paraRange],
}];
}
}
}
searchLoc = NSMaxRange(paraRange);
if (searchLoc >= length) break;
}
self.outlineContents = [outline copy];
}
// ============================================================================
#pragma mark - Free Trial Cutoff
// ============================================================================
/// Returns the real string location accounting for typesetting differences.
/// The raw cutoff location from the server may differ from the position in
/// the typeset attributed string due to inserted image attachments, etc.
- (NSUInteger)freeTrialChapterCutOffRealStringLocation {
if (self.freeTrialCutOffLocation == NSNotFound) {
return NSNotFound;
}
// In the real implementation, this maps from the source string index
// to the typeset string index, accounting for:
// - Image attachment characters () inserted during typesetting.
// - Font substitution changes in string length (rare).
//
// Simple approach: walk both strings in parallel, counting the offset.
NSUInteger sourceLoc = self.freeTrialCutOffLocation;
NSAttributedString *source = self.sourceAttributedString;
NSMutableAttributedString *typeset = self.typesetAttributedString;
if (!source || !typeset) return sourceLoc;
// If the source and typeset strings have the same length, no mapping needed.
if (source.length == typeset.length) return sourceLoc;
// Otherwise, use a character-by-character mapping.
// This is a simplified version; the real code may use a precomputed map.
NSUInteger typesetLoc = 0;
NSUInteger sourceIdx = 0;
NSString *sourcePlain = [source string];
NSString *typesetPlain = [typeset string];
while (sourceIdx < sourceLoc && typesetLoc < typesetPlain.length) {
// Skip image attachment characters in the typeset string.
unichar tc = [typesetPlain characterAtIndex:typesetLoc];
if (tc == 0xFFFC) { // NSAttachmentCharacter
typesetLoc++;
continue;
}
sourceIdx++;
typesetLoc++;
}
return typesetLoc;
}
/// Sets the free trial cutoff location from the source string index.
- (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location {
self.freeTrialCutOffLocation = location;
}
// ============================================================================
#pragma mark - Property Accessors (Ivar-backed from binary)
// ============================================================================
// The binary shows these ivar types:
// NSMutableAttributedString -> _typesetAttributedString
// NSArray -> _pageRanges
// NSArray -> _highlights
// NSArray -> _underlines
// NSArray -> _tempReviewHighlights
// NSArray -> _outlineContents
// NSArray -> _imageAttachments (used by layout for inline images)
// WRCoreTextLayouter -> _layouter
// NSSet -> _bookmarkedPages
// NSAttributedString -> _sourceAttributedString
// These are standard @property synthesized accessors; the binary confirms
// the backing ivar names and types match.
@end
@@ -0,0 +1,82 @@
//
// WRChapterPageCount.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Pagination calculator. Manages NSRange for each page within a chapter.
// Computes page breaks based on the typeset attributed string and the
// available drawing area.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - WRChapterPageCount
// ============================================================================
@interface WRChapterPageCount : NSObject
// ---- Ivars from binary: NSString, NSString, NSString, NSArray ----
/// The book identifier this page count data belongs to.
@property (nonatomic, copy, nullable) NSString *bookId;
/// The chapter identifier.
@property (nonatomic, copy, nullable) NSString *chapterId;
/// A cache key string combining book and chapter info for disk caching.
@property (nonatomic, copy, nullable) NSString *cacheKey;
/// Array of NSValue-wrapped NSRange values, one per page.
/// Each range is a character range within the chapter's attributed string.
@property (nonatomic, strong, nullable) NSArray<NSValue *> *pageRanges;
/// Total number of pages in this chapter.
@property (nonatomic, assign, readonly) NSUInteger totalPages;
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
/// Generates a cache key string for storing/retrieving pagination data
/// for the given book. The key encodes the book ID and current typesetter
/// settings (font size, line spacing, etc.) so pagination is invalidated
/// when settings change.
///
/// @param bookId The book identifier.
/// @return A unique cache key string, e.g., @"weread_pagcount_{bookId}_{fontSize}_{lineSpacing}".
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId;
/// Calculates the page ranges from a page info dictionary.
/// The page info dictionary typically comes from the server or from local
/// layout computation and contains raw range data.
///
/// @param pageInfo Dictionary with pagination data (e.g., @"ranges" key containing
/// an array of {location, length} dictionaries).
/// @return An array of NSValue-wrapped NSRange values.
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo;
// ============================================================================
#pragma mark - Instance Methods
// ============================================================================
/// Returns the character range for the specified page index.
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex;
/// Returns the page index that contains the given character index.
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex;
/// Recalculates page ranges for the given attributed string and drawing area.
///
/// @param attributedString The typeset chapter content.
/// @param drawingSize The available drawing area size (points).
/// @param margins Content insets / margins.
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
drawingSize:(CGSize)drawingSize
margins:(UIEdgeInsets)margins;
@end
NS_ASSUME_NONNULL_END
+308
View File
@@ -0,0 +1,308 @@
//
// WRChapterPageCount.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the pagination calculator.
// Computes page breaks by simulating CoreText line layout and fitting
// lines into the available vertical space.
//
#import "WRChapterPageCount.h"
#import <CoreText/CoreText.h>
// ============================================================================
#pragma mark - Constants
// ============================================================================
/// Prefix for pagination cache keys.
static NSString *const kPageCountCachePrefix = @"weread_pagcount";
/// Separator used in cache key components.
static NSString *const kCacheKeySeparator = @"_";
// ============================================================================
#pragma mark - WRChapterPageCount ()
// ============================================================================
@interface WRChapterPageCount ()
/// Precomputed page ranges (cached after calculation).
@property (nonatomic, strong) NSMutableArray<NSValue *> *mutablePageRanges;
@end
// ============================================================================
#pragma mark - WRChapterPageCount Implementation
// ============================================================================
@implementation WRChapterPageCount
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)init {
self = [super init];
if (self) {
_mutablePageRanges = [NSMutableArray array];
}
return self;
}
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
///
/// Generates a cache key that encodes the book ID together with the current
/// typesetter configuration (font size, line spacing, margins, page size).
/// When any of these change, the cache key changes, invalidating old data.
///
/// Typical format:
/// weread_pagcount_{bookId}_{fontSize}_{lineSpacing}_{pageWidth}_{pageHeight}
///
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId {
if (!bookId) return kPageCountCachePrefix;
// Read current typesetter settings from NSUserDefaults or a global config.
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
CGFloat fontSize = [defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0;
CGFloat lineSpacing = [defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5;
CGFloat pageWidth = [defaults floatForKey:@"WRTypesetterPageWidth"] ?: 375.0;
CGFloat pageHeight = [defaults floatForKey:@"WRTypesetterPageHeight"] ?: 667.0;
// Build the key. Use integer representations to avoid locale issues.
NSString *key = [NSString stringWithFormat:@"%@%@%@%@%.0f%@%.1f%@%.0f%@%.0f",
kPageCountCachePrefix,
kCacheKeySeparator,
bookId,
kCacheKeySeparator,
fontSize * 10, // e.g., 180 for 18.0pt
kCacheKeySeparator,
lineSpacing * 10, // e.g., 15 for 1.5
kCacheKeySeparator,
pageWidth,
kCacheKeySeparator,
pageHeight];
return key;
}
///
/// Converts a page info dictionary (from server or local computation)
/// into an array of NSValue-wrapped NSRange objects.
///
/// Expected pageInfo format:
/// {
/// @"ranges": @[
/// @{@"location": @0, @"length": @500},
/// @{@"location": @500, @"length": @480},
/// ...
/// ]
/// }
///
/// Or alternatively, an array of two-element arrays:
/// {
/// @"ranges": @[@[@0, @500], @[@500, @480], ...]
/// }
///
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo {
NSMutableArray<NSValue *> *result = [NSMutableArray array];
if (!pageInfo) return [result copy];
NSArray *ranges = pageInfo[@"ranges"];
if (!ranges || ![ranges isKindOfClass:[NSArray class]]) return [result copy];
for (id entry in ranges) {
NSRange range = NSMakeRange(NSNotFound, 0);
if ([entry isKindOfClass:[NSDictionary class]]) {
// Dictionary format: {location, length}
NSDictionary *dict = (NSDictionary *)entry;
NSUInteger loc = [dict[@"location"] unsignedIntegerValue];
NSUInteger len = [dict[@"length"] unsignedIntegerValue];
range = NSMakeRange(loc, len);
} else if ([entry isKindOfClass:[NSArray class]]) {
// Array format: [location, length]
NSArray *arr = (NSArray *)entry;
if (arr.count >= 2) {
NSUInteger loc = [arr[0] unsignedIntegerValue];
NSUInteger len = [arr[1] unsignedIntegerValue];
range = NSMakeRange(loc, len);
}
} else if ([entry isKindOfClass:[NSValue class]]) {
// Already an NSValue wrapping NSRange.
range = [(NSValue *)entry rangeValue];
}
if (range.location != NSNotFound) {
[result addObject:[NSValue valueWithRange:range]];
}
}
return [result copy];
}
// ============================================================================
#pragma mark - Instance Methods
// ============================================================================
/// Returns the character range for the page at the given index.
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex {
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
if (pageIndex >= ranges.count) {
return NSMakeRange(NSNotFound, 0);
}
return [ranges[pageIndex] rangeValue];
}
/// Binary-search-style lookup: finds the page index containing the given
/// character index. Pages are contiguous, so we can use binary search.
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex {
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
if (ranges.count == 0) return 0;
NSUInteger lo = 0;
NSUInteger hi = ranges.count - 1;
while (lo <= hi) {
NSUInteger mid = lo + (hi - lo) / 2;
NSRange midRange = [ranges[mid] rangeValue];
if (charIndex >= midRange.location &&
charIndex < NSMaxRange(midRange)) {
return mid;
} else if (charIndex < midRange.location) {
if (mid == 0) break;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
// If not found, return the last page (clamp).
return ranges.count - 1;
}
///
/// Recalculates page ranges by simulating CoreText typesetting.
/// This is the core pagination algorithm:
///
/// 1. Create a CTFramesetter from the attributed string.
/// 2. For each page, create a CTFrame with the available height.
/// 3. Count how many lines fit in the frame.
/// 4. Sum the character counts of those lines to get the page range.
/// 5. Advance the start position and repeat.
///
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
drawingSize:(CGSize)drawingSize
margins:(UIEdgeInsets)margins {
[self.mutablePageRanges removeAllObjects];
if (!attributedString || attributedString.length == 0) {
self.pageRanges = @[];
return;
}
// ---- Step 1: Create the CTFramesetter ----
CTFramesetterRef framesetter =
CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributedString);
if (!framesetter) {
self.pageRanges = @[];
return;
}
// ---- Step 2: Compute the usable drawing area ----
CGFloat usableWidth = drawingSize.width - margins.left - margins.right;
CGFloat usableHeight = drawingSize.height - margins.top - margins.bottom;
if (usableWidth <= 0 || usableHeight <= 0) {
CFRelease(framesetter);
self.pageRanges = @[];
return;
}
// ---- Step 3: Paginate ----
NSUInteger totalLength = attributedString.length;
NSUInteger currentLocation = 0;
while (currentLocation < totalLength) {
// Create a path for this page's drawing area.
CGRect pathRect = CGRectMake(margins.left, margins.bottom,
usableWidth, usableHeight);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, pathRect);
// Create a frame for the remaining text.
// The frame will typeset as much text as fits in the path.
CFRange frameRange = CFRangeMake((CFIndex)currentLocation, 0); // 0 = until end
CTFrameRef frame = CTFramesetterCreateFrame(framesetter,
frameRange,
path, NULL);
CGPathRelease(path);
if (!frame) break;
// Get the lines that fit in this page.
NSArray *lines = (__bridge_transfer NSArray *)CTFrameGetLines(frame);
if (lines.count == 0) {
CFRelease(frame);
break;
}
// Get the line origins to determine which lines are fully within bounds.
CGPoint *origins = (CGPoint *)calloc(lines.count, sizeof(CGPoint));
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
NSUInteger pageCharCount = 0;
for (NSUInteger i = 0; i < lines.count; i++) {
CTLineRef line = (__bridge CTLineRef)lines[i];
CFRange lineRange = CTLineGetStringRange(line);
// Check if this line's origin is within the usable area.
// CoreText origins are from the bottom of the frame.
CGFloat lineY = origins[i].y;
CGFloat lineHeight = 0.0;
CGFloat descent = 0.0;
CTLineGetTypographicBounds(line, &lineHeight, &descent, NULL);
// If the line's top is above the top of the frame, it doesn't fit.
if (lineY - lineHeight > usableHeight) {
break; // This line doesn't fit; stop here.
}
pageCharCount += (NSUInteger)lineRange.length;
}
free(origins);
CFRelease(frame);
// If no characters fit (shouldn't happen normally), advance by 1 to avoid infinite loop.
if (pageCharCount == 0) {
pageCharCount = 1;
}
// Record this page's range.
NSRange pageRange = NSMakeRange(currentLocation, pageCharCount);
[self.mutablePageRanges addObject:[NSValue valueWithRange:pageRange]];
currentLocation += pageCharCount;
}
CFRelease(framesetter);
self.pageRanges = [self.mutablePageRanges copy];
}
// ============================================================================
#pragma mark - Computed Properties
// ============================================================================
- (NSUInteger)totalPages {
return self.pageRanges.count;
}
@end
@@ -0,0 +1,349 @@
//
// WRCoreTextLayoutFrame.h
// WeRead
//
// Reverse-engineered from binary analysis
// Single page layout frame for WeRead's reading engine
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <CoreText/CoreText.h>
@class WRBookCoverView;
@class WRDiscover;
NS_ASSUME_NONNULL_BEGIN
#pragma mark - Layout Line
/**
* Represents a single line of laid-out text.
* Wrapper around CTLine with additional metadata.
*/
@interface WRCoreTextLayoutLine : NSObject
/** The CTLine this wraps */
@property (nonatomic, assign, readonly) CTLineRef ctLine;
/** Origin point of this line in the frame */
@property (nonatomic, assign) CGPoint origin;
/** The range of characters in this line */
@property (nonatomic, assign) NSRange stringRange;
/** Ascent of the line */
@property (nonatomic, assign, readonly) CGFloat ascent;
/** Desent of the line */
@property (nonatomic, assign, readonly) CGFloat descent;
/** Leading of the line */
@property (nonatomic, assign, readonly) CGFloat leading;
/** Total height of the line (ascent + descent + leading) */
@property (nonatomic, assign, readonly) CGFloat height;
/** Width of the line */
@property (nonatomic, assign, readonly) CGFloat width;
/** Whether this line is the last line in a paragraph */
@property (nonatomic, assign) BOOL isLastLineInParagraph;
@end
#pragma mark - WRCoreTextLayoutFrame
/**
* WRCoreTextLayoutFrame - Manages a single page of text layout.
*
* This class wraps CTFrame and provides high-level access to the
* layout of a single page. It handles:
* - Text rendering to CGContext
* - Image placement within text
* - avoidPageBreakInside CSS property
* - Render height calculation
* - Line-by-line access for hit testing and selection
*
* Architecture:
* - Contains CTFrame internally
* - Manages array of WRCoreTextLayoutLine objects
* - Supports direct drawing to CGContext
* - Handles image attachments inline
* - Integrates with RACSubject for reactive updates
*/
@interface WRCoreTextLayoutFrame : NSObject
#pragma mark - Core Layout Data
/** The attributed string that was laid out */
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
/** The range of the attributed string represented by this frame */
@property (nonatomic, assign) NSRange stringRange;
/** Array of WRCoreTextLayoutLine objects for this frame */
@property (nonatomic, strong, readonly) NSArray<WRCoreTextLayoutLine *> *layoutLines;
/** The bounding rectangle for this frame */
@property (nonatomic, assign) CGRect frame;
/** The CTFrame reference (accessor for internal use) */
@property (nonatomic, assign, readonly) CTFrameRef ctFrame;
#pragma mark - Layout Properties
/** Whether to avoid page breaks inside certain elements */
@property (nonatomic, assign) BOOL avoidPageBreakInside;
/** The rendered content height (may differ from frame height) */
@property (nonatomic, assign, readonly) CGFloat renderedContentHeight;
/** Padding around the content area */
@property (nonatomic, assign) UIEdgeInsets contentInsets;
/** Number of columns in this frame */
@property (nonatomic, assign) NSUInteger numberOfColumns;
/** Gap between columns */
@property (nonatomic, assign) CGFloat columnGap;
#pragma mark - Content Arrays
/** Array of attachment objects (images, etc.) */
@property (nonatomic, strong, nullable) NSArray *attachments;
/** Array of strikethrough ranges */
@property (nonatomic, strong, nullable) NSArray *strikethroughRanges;
/** Array of underline ranges */
@property (nonatomic, strong, nullable) NSArray *underlineRanges;
/** Array of highlight ranges */
@property (nonatomic, strong, nullable) NSArray *highlightRanges;
/** Set of selected line indices */
@property (nonatomic, strong, nullable) NSMutableSet *selectedLineIndices;
/** Current selection string */
@property (nonatomic, copy, nullable) NSString *selectionString;
/** Array of link ranges for tap handling */
@property (nonatomic, strong, nullable) NSArray *linkRanges;
/** Search result string */
@property (nonatomic, copy, nullable) NSString *searchResultString;
/** Array of search result ranges */
@property (nonatomic, strong, nullable) NSArray *searchResultRanges;
#pragma mark - Reactive Components
/** Subject for layout change notifications */
@property (nonatomic, strong, nullable) RACSubject *layoutChangeSubject;
#pragma mark - UI Elements
/** Long press gesture recognizer for text selection */
@property (nonatomic, strong, nullable) UILongPressGestureRecognizer *longPressRecognizer;
/** Book cover view (for cover pages) */
@property (nonatomic, strong, nullable) WRBookCoverView *bookCoverView;
/** Layer for custom drawing */
@property (nonatomic, strong, nullable) CALayer *customDrawingLayer;
/** Discover view (for social features) */
@property (nonatomic, strong, nullable) WRDiscover *discoverView;
/** Additional layout data */
@property (nonatomic, strong, nullable) NSArray *additionalLayoutData;
/** Frame identifier */
@property (nonatomic, copy, nullable) NSString *frameId;
/** Content identifier */
@property (nonatomic, copy, nullable) NSString *contentId;
/** Mutable dictionary for custom attributes */
@property (nonatomic, strong, nullable) NSMutableDictionary *customAttributes;
/** Mutable array for tracking visible elements */
@property (nonatomic, strong, nullable) NSMutableArray *visibleElements;
/** Mutable array for tracking accessibility elements */
@property (nonatomic, strong, nullable) NSMutableArray *accessibilityElements;
/** Mutable dictionary for caching computed values */
@property (nonatomic, strong, nullable) NSMutableDictionary *computedValueCache;
#pragma mark - Initialization
/**
* Initialize with a CTFrame and string range.
*
* @param ctFrame The CoreText frame
* @param range The string range
* @return Initialized layout frame
*/
- (instancetype)initWithCTFrame:(CTFrameRef)ctFrame
range:(NSRange)range;
/**
* Set the CTFrame and range (used by WRCoreTextLayouter).
*/
- (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range;
#pragma mark - Line Access
/**
* Returns the array of layout lines.
* This triggers line extraction from the CTFrame if not already done.
*
* @return Array of WRCoreTextLayoutLine objects
*/
- (NSArray<WRCoreTextLayoutLine *> *)lines;
/**
* Returns the number of lines in this frame.
*/
- (NSUInteger)lineCount;
/**
* Returns the layout line at the specified index.
*/
- (nullable WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index;
#pragma mark - Height Calculation
/**
* Returns the rendered content height.
*
* This calculates the actual height of the rendered content,
* which may be less than the frame height if there's unused space.
*
* @return The height of the rendered content in points
*/
- (CGFloat)getRenderHeight;
/**
* Returns the height of the last line's descent.
* Used for precise baseline alignment.
*/
- (CGFloat)lastLineDescent;
#pragma mark - Drawing
/**
* Draw the layout frame content into a CGContext.
*
* This is the primary rendering method. It draws:
* 1. Text content using CTFrameDraw
* 2. Image attachments at their calculated positions
* 3. Decorative elements (strikethrough, underline, etc.)
*
* @param context The CGContext to draw into
* @param image Optional image to draw (for cover pages)
* @param size The size of the drawing area
* @param rect The rectangle to draw within
* @param position The position offset for the content
*/
- (void)drawInContext:(CGContextRef)context
image:(nullable UIImage *)image
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position;
/**
* Draw only the text content (no images).
*/
- (void)drawTextInContext:(CGContextRef)context
inRect:(CGRect)rect;
/**
* Draw image attachments.
*/
- (void)drawAttachmentsInContext:(CGContextRef)context
inRect:(CGRect)rect;
#pragma mark - Page Break Avoidance
/**
* Removes the last lines if needed to avoid page breaks inside certain elements.
*
* This implements the CSS 'avoid-page-break-inside' property. When an element
* (like a table, code block, or list) cannot fit entirely on the current page,
* this method removes lines from the end until the element can fit.
*
* @return YES if lines were removed, NO otherwise
*/
- (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded;
/**
* Check if a range of text has the avoid-page-break-inside property.
*/
- (BOOL)shouldAvoidPageBreakInRange:(NSRange)range;
#pragma mark - Hit Testing
/**
* Returns the character index at a given point.
*
* @param point The point to test (in the frame's coordinate system)
* @return The character index, or NSNotFound if no character at that point
*/
- (NSUInteger)characterIndexAtPoint:(CGPoint)point;
/**
* Returns the line index at a given point.
*
* @param point The point to test
* @return The line index, or NSNotFound
*/
- (NSUInteger)lineIndexAtPoint:(CGPoint)point;
/**
* Returns the rect for a character at the given index.
*/
- (CGRect)rectForCharacterAtIndex:(NSUInteger)index;
#pragma mark - Selection
/**
* Returns the selected text string.
*/
- (nullable NSString *)selectedText;
/**
* Returns the ranges of selected text.
*/
- (NSArray<NSValue *> *)selectedRanges;
/**
* Selects text in the given range.
*/
- (void)selectTextInRange:(NSRange)range;
/**
* Clears the current selection.
*/
- (void)clearSelection;
#pragma mark - Search
/**
* Highlights search results within this frame.
*
* @param searchString The string to highlight
* @return The number of matches found
*/
- (NSUInteger)highlightSearchResults:(NSString *)searchString;
/**
* Clears all search result highlights.
*/
- (void)clearSearchHighlights;
@end
NS_ASSUME_NONNULL_END
@@ -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
+291
View File
@@ -0,0 +1,291 @@
//
// WRCoreTextLayouter.h
// WeRead
//
// Reverse-engineered from binary analysis
// CoreText typesetter wrapper for WeRead's reading engine
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <CoreText/CoreText.h>
@class WRCoreTextLayoutFrame;
@class WRMarkContentChapter;
@class QMUITextField;
@class WRButton;
NS_ASSUME_NONNULL_BEGIN
#pragma mark - Layout Configuration
/**
* Configuration options for text layout.
* Controls how the typesetter processes attributed strings.
*/
@interface WRCoreTextLayoutConfig : NSObject
@property (nonatomic, assign) CGFloat frameWidth;
@property (nonatomic, assign) CGFloat frameHeight;
@property (nonatomic, assign) UIEdgeInsets edgeInsets;
@property (nonatomic, assign) NSUInteger numberOfColumns;
@property (nonatomic, assign) CGFloat columnGap;
@property (nonatomic, assign) BOOL avoidOrphans;
@property (nonatomic, assign) BOOL avoidWidows;
@property (nonatomic, assign) BOOL hyphenation;
@end
#pragma mark - WRCoreTextLayouter
/**
* WRCoreTextLayouter - CoreText typesetter wrapper for WeRead.
*
* This class wraps CTTypesetter and CTFramesetter to provide high-level
* text layout functionality. It takes an NSAttributedString and produces
* WRCoreTextLayoutFrame objects representing individual pages.
*
* Architecture:
* - Creates CTTypesetter from attributed string
* - Manages typesetter lifecycle and caching
* - Produces layout frames for pagination
* - Handles image resizing and page backgrounds
* - Integrates with WeRead's theme system
*/
@interface WRCoreTextLayouter : NSObject
#pragma mark - Text Content
/** The original plain text string */
@property (nonatomic, strong, nullable) NSString *plainText;
/** The attributed string to be laid out */
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
/** A copy of the attributed string for internal modifications */
@property (nonatomic, strong, nullable) NSAttributedString *internalAttributedString;
#pragma mark - UI Components (likely from parent view hierarchy)
/** Reference to the containing view controller */
@property (nonatomic, weak, nullable) UIViewController *viewController;
/** Reference to the scroll view for pagination */
@property (nonatomic, weak, nullable) UIScrollView *scrollView;
/** Search text field (for find-in-book functionality) */
@property (nonatomic, strong, nullable) QMUITextField *searchField;
/** Another text field (possibly for page jump) */
@property (nonatomic, strong, nullable) QMUITextField *pageInputField;
/** Button for actions */
@property (nonatomic, strong, nullable) WRButton *actionButton;
/** Layer for decorations */
@property (nonatomic, strong, nullable) CALayer *decorationLayer;
/** Layer for shadow/overlay effects */
@property (nonatomic, strong, nullable) CALayer *shadowLayer;
/** Title label */
@property (nonatomic, strong, nullable) UILabel *titleLabel;
/** Subtitle/info label */
@property (nonatomic, strong, nullable) UILabel *infoLabel;
#pragma mark - Theme and Appearance
/** Background color for the text rendering area */
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
/** Text color */
@property (nonatomic, strong, nullable) UIColor *textColor;
/** Primary font for body text */
@property (nonatomic, strong, nullable) UIFont *bodyFont;
/** Secondary font (for headings, emphasis) */
@property (nonatomic, strong, nullable) UIFont *headingFont;
/** Container view for rendered content */
@property (nonatomic, weak, nullable) UIView *containerView;
#pragma mark - Content Metadata
/** Chapter identifier */
@property (nonatomic, copy, nullable) NSString *chapterId;
/** Book identifier */
@property (nonatomic, copy, nullable) NSString *bookId;
/** Section identifier within chapter */
@property (nonatomic, copy, nullable) NSString *sectionId;
/** Page identifier for current page */
@property (nonatomic, copy, nullable) NSString *pageId;
/** Content source identifier */
@property (nonatomic, copy, nullable) NSString *sourceId;
/** Unique layout identifier */
@property (nonatomic, copy, nullable) NSString *layoutId;
/** Rendering mode identifier */
@property (nonatomic, copy, nullable) NSString *renderMode;
/** Theme identifier */
@property (nonatomic, copy, nullable) NSString *themeId;
/** Additional layout attributes dictionary */
@property (nonatomic, strong, nullable) NSDictionary *layoutAttributes;
/** Array of embedded attachments (images, etc.) */
@property (nonatomic, strong, nullable) NSArray *attachments;
/** Reference to the chapter content model */
@property (nonatomic, strong, nullable) WRMarkContentChapter *chapter;
/** Current pagination cursor string */
@property (nonatomic, copy, nullable) NSString *paginationCursor;
/** Mutable array of layout frames (pages) */
@property (nonatomic, strong, nullable) NSMutableArray<WRCoreTextLayoutFrame *> *layoutFrames;
#pragma mark - Initialization
/**
* Initialize with an attributed string for layout.
*
* @param attributedString The text with styling attributes
* @return Initialized layouter instance
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString;
/**
* Initialize with attributed string and layout configuration.
*
* @param attributedString The text with styling attributes
* @param config Layout configuration options
* @return Initialized layouter instance
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
config:(WRCoreTextLayoutConfig *)config;
#pragma mark - Typesetter Management
/**
* Create or recreate the internal CTTypesetter.
* Called when the attributed string changes.
*/
- (void)createTypesetter;
/**
* Create or recreate the internal CTFramesetter.
* Called when frame-based layout is needed.
*/
- (void)createFramesetter;
/**
* Invalidate the current typesetter, forcing recreation on next use.
*/
- (void)invalidateTypesetter;
#pragma mark - Layout Frame Creation
/**
* Create a layout frame for a given string range within the specified rect.
*
* @param range The range of the attributed string to lay out
* @param frame The bounding rectangle for the layout
* @return A new WRCoreTextLayoutFrame for the given range
*/
- (WRCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame;
/**
* Create layout frames for the entire attributed string.
* Breaks content into pages based on the frame size.
*
* @param pageSize The size of each page
* @return Array of WRCoreTextLayoutFrame objects
*/
- (NSArray<WRCoreTextLayoutFrame *> *)layoutFramesForPageSize:(CGSize)pageSize;
/**
* Create a single layout frame for the given range.
*
* @param range The range of text to lay out
* @param rect The bounding rectangle
* @param columns Number of columns
* @return A layout frame or nil if range is invalid
*/
- (nullable WRCoreTextLayoutFrame *)layoutFrameForRange:(NSRange)range
rect:(CGRect)rect
columns:(NSUInteger)columns;
#pragma mark - Page Background Images
/**
* Get the page background image for a given text range.
*
* @param range The range of text on the page
* @param themeBgColor The current theme's background color
* @return A UIImage for the page background, or nil
*/
- (nullable UIImage *)pageBackgroundImageAtRange:(NSRange)range
themeBgColor:(UIColor *)themeBgColor;
#pragma mark - Image Utilities
/**
* Resize an image for display within the layout.
*
* @param imagePath Path or URL string for the image
* @param rect The target rectangle for the image
* @param position The position within the layout
* @param sizePattern The size pattern to apply
* @param darkMode Whether dark mode is active
* @param themeBgColor The theme background color for blending
* @return A resized UIImage
*/
- (UIImage *)resizedImageForImagePath:(NSString *)imagePath
rect:(CGRect)rect
position:(NSUInteger)position
sizePattern:(NSString *)sizePattern
darkMode:(BOOL)darkMode
themeBgColor:(UIColor *)themeBgColor;
#pragma mark - Pagination
/**
* Calculate the number of pages for the given page size.
*
* @param pageSize The size of each page
* @return The total number of pages
*/
- (NSUInteger)numberOfPagesForPageSize:(CGSize)pageSize;
/**
* Get the text range for a specific page.
*
* @param pageIndex The page index (0-based)
* @param pageSize The size of each page
* @return The NSRange of text on the specified page
*/
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex
pageSize:(CGSize)pageSize;
#pragma mark - Suggested Line Heights
/**
* Get suggested line fragment heights for the current content.
* Used for precise pagination calculations.
*
* @return Array of NSNumber values representing line heights
*/
- (NSArray<NSNumber *> *)suggestedLineFragHeights;
@end
NS_ASSUME_NONNULL_END
+886
View File
@@ -0,0 +1,886 @@
//
// 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
+111
View File
@@ -0,0 +1,111 @@
//
// WREpubParser.h
// WeRead (微信读书)
// Reverse-engineered header
//
// EPUB file parser. Parses OPF (content.opf), NCX (toc.ncx), and XHTML chapter files.
// Resolves EPUB structure: container.xml -> content.opf -> spine -> chapters.
// Returns chapter list and resource mapping.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WHAlbumInfo;
// ---------------------------------------------------------------------------
// Error domain and codes for EPUB parsing failures
// ---------------------------------------------------------------------------
extern NSString *const WREpubParserErrorDomain;
typedef NS_ENUM(NSInteger, WREpubParserErrorCode) {
WREpubParserErrorFileNotFound = -1000,
WREpubParserErrorContainerParseFail = -1001,
WREpubParserErrorOPFParseFail = -1002,
WREpubParserErrorNCXParseFail = -1003,
WREpubParserErrorSpineEmpty = -1004,
WREpubParserErrorChapterLoadFail = -1005,
WREpubParserErrorDecryptionFail = -1006,
};
// ---------------------------------------------------------------------------
// WREpubParserDelegate
// ---------------------------------------------------------------------------
@protocol WREpubParserDelegate <NSObject>
@optional
/// Called when the EPUB controller encounters a fatal parsing error.
/// The parser invokes this on the delegate (typically a UIViewController)
/// so the UI layer can present the error to the user.
- (void)epubController:(id)controller didFailWithError:(NSError *)error;
@end
// ---------------------------------------------------------------------------
// WREpubParser
// ---------------------------------------------------------------------------
@interface WREpubParser : NSObject
// --- Ivars (from binary analysis) ---
// {
// NSString *_epubFilePath; // path to the .epub file on disk
// NSString *_baseDirectory; // extracted root directory
// NSError *_lastError; // most recent parse error
// UIViewController *_epubController; // weak ref to presenting controller
// WRBook *_book; // associated book model
// WHAlbumInfo *_albumInfo; // album / collection metadata
// }
@property (nonatomic, copy, readonly) NSString *epubFilePath;
@property (nonatomic, copy, readonly) NSString *baseDirectory;
@property (nonatomic, strong, readonly, nullable) NSError *lastError;
@property (nonatomic, weak, nullable) id<WREpubParserDelegate> delegate;
@property (nonatomic, strong, readonly, nullable) WRBook *book;
@property (nonatomic, strong, readonly, nullable) WHAlbumInfo *albumInfo;
/// Chapters parsed from the spine, in reading order.
@property (nonatomic, strong, readonly) NSArray<NSDictionary *> *chapters;
/// Resource map: relative path -> absolute path for images, CSS, fonts, etc.
@property (nonatomic, strong, readonly) NSDictionary<NSString *, NSString *> *resourceMap;
/// Ordered list of spine item IDs (for navigation).
@property (nonatomic, strong, readonly) NSArray<NSString *> *spineItemIDs;
#pragma mark - Initialization
- (instancetype)initWithFilePath:(NSString *)path
book:(nullable WRBook *)book;
#pragma mark - Parsing
/// Parse the EPUB archive. Returns YES on success.
- (BOOL)parse:(NSError *_Nullable *_Nullable)error;
/// Parse container.xml and return the path to the OPF file.
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error;
/// Parse content.opf and populate chapters + resourceMap.
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
error:(NSError *_Nullable *_Nullable)error;
/// Parse toc.ncx and return the table-of-contents tree.
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error;
/// Read and return the XHTML content of a single chapter.
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
error:(NSError *_Nullable *_Nullable)error;
/// Resolve a relative resource path to an absolute file path.
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath;
#pragma mark - Delegate callback (internal)
- (void)notifyDelegateOfError:(NSError *)error;
@end
NS_ASSUME_NONNULL_END
+607
View File
@@ -0,0 +1,607 @@
//
// WREpubParser.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
// and contextual knowledge of EPUB file format handling.
//
#import "WREpubParser.h"
#import "WRBook.h"
#import "WHAlbumInfo.h"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
NSString *const WREpubParserErrorDomain = @"WREpubParserErrorDomain";
static NSString *const kContainerXMLPath = @"META-INF/container.xml";
static NSString *const kOPFMIMEType = @"application/oebps-package+xml";
static NSString *const kNCXMIMEType = @"application/x-dtbncx+xml";
#pragma mark - Private Interface
@interface WREpubParser ()
@property (nonatomic, copy, readwrite) NSString *epubFilePath;
@property (nonatomic, copy, readwrite) NSString *baseDirectory;
@property (nonatomic, strong, readwrite, nullable) NSError *lastError;
@property (nonatomic, strong, readwrite) NSArray<NSDictionary *> *chapters;
@property (nonatomic, strong, readwrite) NSDictionary<NSString *, NSString *> *resourceMap;
@property (nonatomic, strong, readwrite) NSArray<NSString *> *spineItemIDs;
@end
#pragma mark - Implementation
@implementation WREpubParser
{
// Ivars confirmed from binary analysis:
NSString *_epubFilePath;
NSString *_baseDirectory;
NSError *_lastError;
UIViewController *_epubController; // weak, assigned from delegate
WRBook *_book;
WHAlbumInfo *_albumInfo;
// Internal caches (not exported in header but inferred):
NSMutableDictionary<NSString *, NSString *> *_manifestMap; // id -> href
NSMutableDictionary<NSString *, NSString *> *_mediaTypeMap; // id -> media-type
NSMutableArray<NSString *> *_spineRefs; // idref list
}
#pragma mark - Lifecycle
- (instancetype)initWithFilePath:(NSString *)path
book:(nullable WRBook *)book
{
self = [super init];
if (self) {
_epubFilePath = [path copy];
_book = book;
_manifestMap = [NSMutableDictionary dictionary];
_mediaTypeMap = [NSMutableDictionary dictionary];
_spineRefs = [NSMutableArray array];
_chapters = @[];
_resourceMap = @{};
_spineItemIDs = @[];
}
return self;
}
#pragma mark - Public: Parsing
- (BOOL)parse:(NSError *_Nullable *_Nullable)error
{
// 1. Locate the OPF path from container.xml
NSError *containerError = nil;
NSString *opfRelPath = [self parseContainerXML:&containerError];
if (!opfRelPath) {
[self _setError:error
code:WREpubParserErrorContainerParseFail
description:@"Failed to parse container.xml"
underlyingError:containerError];
return NO;
}
// 2. Parse the OPF to populate manifest, spine, and metadata
NSError *opfError = nil;
if (![self parseOPFAtRelativePath:opfRelPath error:&opfError]) {
[self _setError:error
code:WREpubParserErrorOPFParseFail
description:@"Failed to parse content.opf"
underlyingError:opfError];
return NO;
}
// 3. Optionally parse NCX for table of contents
NSError *ncxError = nil;
[self parseNCX:&ncxError];
// NCX failure is non-fatal; log but continue
if (ncxError) {
NSLog(@"[WREpubParser] NCX parse warning: %@", ncxError);
}
// 4. Build the chapter list from the spine
[self _buildChapterList];
// 5. Build the resource map from the manifest
[self _buildResourceMap];
if (self.chapters.count == 0) {
[self _setError:error
code:WREpubParserErrorSpineEmpty
description:@"Spine contains no items"
underlyingError:nil];
return NO;
}
return YES;
}
// ---------------------------------------------------------------------------
// Parse META-INF/container.xml
// ---------------------------------------------------------------------------
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error
{
NSString *containerPath =
[self.epubFilePath stringByAppendingPathComponent:kContainerXMLPath];
// container.xml is always plain text (unencrypted)
NSData *data = [NSData dataWithContentsOfFile:containerPath options:0 error:error];
if (!data) return nil;
// Use NSXMLParser to extract the rootfile full-path
//
// Expected structure:
// <container>
// <rootfiles>
// <rootfile full-path="OEBPS/content.opf" media-type="..."/>
// </rootfiles>
// </container>
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
WREpubContainerParserDelegate *delegate =
[[WREpubContainerParserDelegate alloc] init];
parser.delegate = delegate;
if (![parser parse]) {
if (error) *error = parser.parserError;
return nil;
}
return delegate.rootFilePath;
}
// ---------------------------------------------------------------------------
// Parse the OPF (content.opf) file
// ---------------------------------------------------------------------------
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
error:(NSError *_Nullable *_Nullable)error
{
NSString *opfFullPath =
[self.epubFilePath stringByAppendingPathComponent:opfRelPath];
// Set the base directory for resolving relative paths within the OPF
_baseDirectory = [opfFullPath stringByDeletingLastPathComponent];
NSData *data = [NSData dataWithContentsOfFile:opfFullPath options:0 error:error];
if (!data) return NO;
// Use NSXMLParser to walk the OPF XML
//
// Sections to parse:
// <manifest> -> populate _manifestMap and _mediaTypeMap
// <spine> -> populate _spineRefs (ordered idref list)
// <metadata> -> extract title, identifier, etc. for WRBook
WREpubOPFParserDelegate *opfDelegate =
[[WREpubOPFParserDelegate alloc] initWithBaseDirectory:_baseDirectory];
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
parser.delegate = opfDelegate;
if (![parser parse]) {
if (error) *error = parser.parserError;
return NO;
}
// Transfer parsed data
[_manifestMap setDictionary:opfDelegate.manifestItems];
[_mediaTypeMap setDictionary:opfDelegate.mediaTypes];
[_spineRefs setArray:opfDelegate.spineItemRefs];
// Extract metadata into _book if available
if (opfDelegate.bookTitle) {
_book.title = opfDelegate.bookTitle;
}
return YES;
}
// ---------------------------------------------------------------------------
// Parse toc.ncx for table of contents
// ---------------------------------------------------------------------------
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error
{
// Find the NCX item in the manifest (media-type = application/x-dtbncx+xml)
NSString *ncxID = nil;
for (NSString *itemID in _mediaTypeMap) {
if ([_mediaTypeMap[itemID] isEqualToString:kNCXMIMEType]) {
ncxID = itemID;
break;
}
}
if (!ncxID) {
// Some EPUBs use nav.xhtml instead of NCX (EPUB3)
// Try finding nav document
return nil;
}
NSString *ncxRelPath = _manifestMap[ncxID];
if (!ncxRelPath) return nil;
NSString *ncxFullPath =
[_baseDirectory stringByAppendingPathComponent:ncxRelPath];
NSData *data = [NSData dataWithContentsOfFile:ncxFullPath options:0 error:error];
if (!data) return nil;
// Parse NCX XML:
// <ncx>
// <navMap>
// <navPoint id="..." playOrder="1">
// <navLabel><text>Chapter 1</text></navLabel>
// <content src="chapter1.xhtml"/>
// <navPoint> ... nested ... </navPoint>
// </navPoint>
// </navMap>
// </ncx>
WREpubNCXParserDelegate *ncxDelegate = [[WREpubNCXParserDelegate alloc] init];
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
parser.delegate = ncxDelegate;
if (![parser parse]) {
if (error) *error = parser.parserError;
return nil;
}
return ncxDelegate.tocEntries;
}
// ---------------------------------------------------------------------------
// Return chapter content (XHTML) for a given index
// ---------------------------------------------------------------------------
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
error:(NSError *_Nullable *_Nullable)error
{
if (index >= self.chapters.count) return nil;
NSDictionary *chapterInfo = self.chapters[index];
NSString *href = chapterInfo[@"href"];
if (!href) return nil;
NSString *fullPath =
[_baseDirectory stringByAppendingPathComponent:href];
// If the file is encrypted, it must be decrypted first via WREncryptedFileManager
NSData *data = [NSData dataWithContentsOfFile:fullPath options:0 error:error];
if (!data) return nil;
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
// ---------------------------------------------------------------------------
// Resolve a relative resource path
// ---------------------------------------------------------------------------
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath
{
if (!relativePath) return nil;
// Try the resource map first
NSString *mapped = self.resourceMap[relativePath];
if (mapped) return mapped;
// Fallback: resolve relative to the base directory
return [_baseDirectory stringByAppendingPathComponent:relativePath];
}
// ---------------------------------------------------------------------------
// Delegate callback
// ---------------------------------------------------------------------------
- (void)notifyDelegateOfError:(NSError *)error
{
_lastError = error;
// The delegate protocol method uses epubController:didFailWithError:
// The "controller" here is the UIViewController that owns the reader.
if ([self.delegate respondsToSelector:@selector(epubController:didFailWithError:)]) {
[self.delegate epubController:_epubController didFailWithError:error];
}
}
#pragma mark - Private Helpers
/// Build the chapters array from spine references + manifest.
- (void)_buildChapterList
{
NSMutableArray *chapters = [NSMutableArray arrayWithCapacity:_spineRefs.count];
for (NSString *idref in _spineRefs) {
NSString *href = _manifestMap[idref];
NSString *mediaType = _mediaTypeMap[idref];
if (!href) continue;
NSMutableDictionary *chapterInfo = [NSMutableDictionary dictionary];
chapterInfo[@"id"] = idref;
chapterInfo[@"href"] = href;
chapterInfo[@"mediaType"] = mediaType ?: @"application/xhtml+xml";
chapterInfo[@"fullPath"] =
[_baseDirectory stringByAppendingPathComponent:href];
[chapters addObject:[chapterInfo copy]];
}
_chapters = [chapters copy];
}
/// Build a flat resource map for images, CSS, fonts, etc.
- (void)_buildResourceMap
{
NSMutableDictionary *map = [NSMutableDictionary dictionary];
for (NSString *itemID in _manifestMap) {
NSString *href = _manifestMap[itemID];
if (!href) continue;
NSString *absPath =
[_baseDirectory stringByAppendingPathComponent:href];
map[href] = absPath;
// Also index by filename for convenience
NSString *filename = [href lastPathComponent];
if (filename) {
map[filename] = absPath;
}
}
_resourceMap = [map copy];
}
/// Helper to set the error pointer and store lastError.
- (void)_setError:(NSError *_Nullable *_Nullable)outError
code:(WREpubParserErrorCode)code
description:(NSString *)description
underlyingError:(nullable NSError *)underlyingError
{
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
userInfo[NSLocalizedDescriptionKey] = description;
if (underlyingError) {
userInfo[NSUnderlyingErrorKey] = underlyingError;
}
NSError *err = [NSError errorWithDomain:WREpubParserErrorDomain
code:code
userInfo:userInfo];
_lastError = err;
if (outError) *outError = err;
}
@end
// ===========================================================================
// Internal XML Parser Delegates (file-private)
// ===========================================================================
#pragma mark - Container Parser Delegate
/// Parses META-INF/container.xml to extract the rootfile path.
@interface WREpubContainerParserDelegate : NSObject <NSXMLParserDelegate>
@property (nonatomic, copy, nullable) NSString *rootFilePath;
@end
@implementation WREpubContainerParserDelegate
{
BOOL _insideRootfile;
}
- (void)parser:(NSXMLParser *)parser
didStartElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
{
if ([elementName isEqualToString:@"rootfile"]) {
_rootFilePath = attributeDict[@"full-path"];
_insideRootfile = YES;
}
}
- (void)parser:(NSXMLParser *)parser
didEndElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
{
if ([elementName isEqualToString:@"rootfile"]) {
_insideRootfile = NO;
}
}
@end
#pragma mark - OPF Parser Delegate
/// Parses the OPF file: manifest, spine, and metadata sections.
@interface WREpubOPFParserDelegate : NSObject <NSXMLParserDelegate>
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *manifestItems;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *mediaTypes;
@property (nonatomic, strong) NSMutableArray<NSString *> *spineItemRefs;
@property (nonatomic, copy, nullable) NSString *bookTitle;
- (instancetype)initWithBaseDirectory:(NSString *)baseDir;
@end
@implementation WREpubOPFParserDelegate
{
NSString *_baseDirectory;
BOOL _inMetadata;
BOOL _inManifest;
BOOL _inSpine;
NSMutableString *_currentText;
}
- (instancetype)initWithBaseDirectory:(NSString *)baseDir
{
self = [super init];
if (self) {
_baseDirectory = baseDir;
_manifestItems = [NSMutableDictionary dictionary];
_mediaTypes = [NSMutableDictionary dictionary];
_spineItemRefs = [NSMutableArray array];
}
return self;
}
- (void)parser:(NSXMLParser *)parser
didStartElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
{
if ([elementName isEqualToString:@"metadata"]) {
_inMetadata = YES;
} else if ([elementName isEqualToString:@"manifest"]) {
_inManifest = YES;
} else if ([elementName isEqualToString:@"spine"]) {
_inSpine = YES;
}
if (_inManifest && [elementName isEqualToString:@"item"]) {
NSString *itemId = attributeDict[@"id"];
NSString *href = attributeDict[@"href"];
NSString *mediaType = attributeDict[@"media-type"];
if (itemId && href) {
_manifestItems[itemId] = href;
if (mediaType) {
_mediaTypes[itemId] = mediaType;
}
}
}
if (_inSpine && [elementName isEqualToString:@"itemref"]) {
NSString *idref = attributeDict[@"idref"];
if (idref) {
[_spineItemRefs addObject:idref];
}
}
_currentText = [NSMutableString string];
}
- (void)parser:(NSXMLParser *)parser
foundCharacters:(NSString *)string
{
[_currentText appendString:string];
}
- (void)parser:(NSXMLParser *)parser
didEndElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
{
if ([elementName isEqualToString:@"metadata"]) {
_inMetadata = NO;
} else if ([elementName isEqualToString:@"manifest"]) {
_inManifest = NO;
} else if ([elementName isEqualToString:@"spine"]) {
_inSpine = NO;
}
// Extract <dc:title> from metadata
if (_inMetadata && [elementName isEqualToString:@"dc:title"]) {
_bookTitle = [_currentText stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
}
_currentText = nil;
}
@end
#pragma mark - NCX Parser Delegate
/// Parses toc.ncx to extract the table of contents tree.
@interface WREpubNCXParserDelegate : NSObject <NSXMLParserDelegate>
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *tocEntries;
@end
@implementation WREpubNCXParserDelegate
{
BOOL _inNavPoint;
BOOL _inNavLabel;
BOOL _inContent;
BOOL _inText;
NSString *_currentNavPointId;
NSString *_currentLabel;
NSString *_currentSrc;
NSMutableString *_currentText;
NSUInteger _playOrder;
}
- (instancetype)init
{
self = [super init];
if (self) {
_tocEntries = [NSMutableArray array];
}
return self;
}
- (void)parser:(NSXMLParser *)parser
didStartElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
{
_currentText = [NSMutableString string];
if ([elementName isEqualToString:@"navPoint"]) {
_inNavPoint = YES;
_currentNavPointId = attributeDict[@"id"];
_playOrder = [attributeDict[@"playOrder"] integerValue];
} else if ([elementName isEqualToString:@"navLabel"]) {
_inNavLabel = YES;
} else if ([elementName isEqualToString:@"text"]) {
_inText = YES;
} else if ([elementName isEqualToString:@"content"]) {
_inContent = YES;
_currentSrc = attributeDict[@"src"];
}
}
- (void)parser:(NSXMLParser *)parser
foundCharacters:(NSString *)string
{
[_currentText appendString:string];
}
- (void)parser:(NSXMLParser *)parser
didEndElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
{
if ([elementName isEqualToString:@"text"] && _inText) {
_currentLabel = [_currentText stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
_inText = NO;
} else if ([elementName isEqualToString:@"content"]) {
_inContent = NO;
} else if ([elementName isEqualToString:@"navLabel"]) {
_inNavLabel = NO;
} else if ([elementName isEqualToString:@"navPoint"]) {
_inNavPoint = NO;
if (_currentLabel && _currentSrc) {
NSDictionary *entry = @{
@"id" : _currentNavPointId ?: @"",
@"label" : _currentLabel,
@"src" : _currentSrc,
@"playOrder" : @(_playOrder)
};
[_tocEntries addObject:entry];
}
_currentNavPointId = nil;
_currentLabel = nil;
_currentSrc = nil;
}
_currentText = nil;
}
@end
@@ -0,0 +1,98 @@
//
// WREpubPositionConverter.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Position converter between file positions and character positions.
// Used for bookmark synchronization and reading progress tracking.
// Maps (fileIndex, row, column) triples to character offsets in the
// concatenated text of the book.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class NSAttributedString;
// ---------------------------------------------------------------------------
// Position pair structure used in row/column-based lookups
// ---------------------------------------------------------------------------
@interface WRPositionPair : NSObject
@property (nonatomic) NSInteger row;
@property (nonatomic) NSInteger column;
@end
// ---------------------------------------------------------------------------
// WREpubPositionConverter
// ---------------------------------------------------------------------------
@interface WREpubPositionConverter : NSObject
// --- Ivars (from binary analysis) ---
// {
// NSArray *_filePaths; // paths to chapter XHTML files
// NSArray *_attributedStrings; // parsed attributed strings per file
// NSMutableArray *_fileLengths; // character count per file
// NSMutableArray *_cumulativeOffsets; // cumulative character offsets
// NSMutableDictionary *_indexCache; // cached index lookups
// NSMutableDictionary *_stringRangeCache; // cached range lookups
// ... more ...
// }
@property (nonatomic, strong, readonly) NSArray<NSString *> *filePaths;
@property (nonatomic, strong, readonly) NSArray<NSAttributedString *> *attributedStrings;
#pragma mark - Initialization
/// Initialize with file paths and their corresponding attributed strings.
/// @param filePaths Array of chapter file paths (in spine order).
/// @param attributedStrings Array of NSAttributedString for each file.
/// @param offset Base character offset (e.g., for non-chapter content).
/// @param isContainIntroFlyleaf YES if the flyleaf/cover page is included.
- (instancetype)initWithFilePaths:(NSArray<NSString *> *)filePaths
attributedStrings:(NSArray<NSAttributedString *> *)attributedStrings
offset:(NSInteger)offset
isContainIntroFlyleaf:(BOOL)isContainIntroFlyleaf;
#pragma mark - Index Building
/// Build internal index tables for fast position lookup.
/// Must be called before performing conversions.
- (void)initIndices;
#pragma mark - Position Conversion
/// Convert row/column pairs to string indices within a specific file.
/// @param filePath The chapter file path.
/// @param rowColumnPairs Array of WRPositionPair objects.
/// @param stringIndices (out) Array of NSNumber (NSInteger) with resolved indices.
/// @param string The full text string of the file.
/// @param fileIndexOffset Offset to add to the file index.
/// @param stringIndexOffset Offset to add to the resulting string index.
- (void)indicesInFile:(NSString *)filePath
forRowColumnPairs:(NSArray<WRPositionPair *> *)rowColumnPairs
stringIndices:(NSArray *_Nullable *_Nullable)stringIndices
string:(NSString *)string
fileIndexOffset:(NSInteger)fileIndexOffset
stringIndexOffset:(NSInteger)stringIndexOffset;
/// Convert a file-based range (fileIndex, startOffset, endOffset)
/// to a character range in the concatenated book string.
/// @return NSRange in the global string, or NSNotFound if invalid.
- (NSRange)stringRangeFromFileRange:(NSDictionary *)fileRange;
#pragma mark - Utility
/// Return the total character count across all files.
- (NSInteger)totalCharacterCount;
/// Return the file index that contains the given global character position.
- (NSInteger)fileIndexForCharacterPosition:(NSInteger)position;
/// Return the local character offset within a file for a global position.
- (NSInteger)localOffsetInFileAtIndex:(NSInteger)fileIndex
forGlobalPosition:(NSInteger)position;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,326 @@
//
// WREpubPositionConverter.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
// and contextual knowledge of EPUB position/bookmark handling.
//
#import "WREpubPositionConverter.h"
// ---------------------------------------------------------------------------
// WRPositionPair
// ---------------------------------------------------------------------------
@implementation WRPositionPair
- (instancetype)initWithRow:(NSInteger)row column:(NSInteger)column
{
self = [super init];
if (self) {
_row = row;
_column = column;
}
return self;
}
- (NSString *)description
{
return [NSString stringWithFormat:@"<WRPositionPair row=%ld col=%ld>",
(long)_row, (long)_column];
}
@end
// ---------------------------------------------------------------------------
// WREpubPositionConverter
// ---------------------------------------------------------------------------
@implementation WREpubPositionConverter
{
// Core data from binary analysis
NSArray *_filePaths;
NSArray *_attributedStrings;
NSMutableArray *_fileLengths; // character count per file
NSMutableArray *_cumulativeOffsets; // prefix sum of character counts
NSMutableDictionary *_indexCache; // cache: key -> index
NSMutableDictionary *_stringRangeCache; // cache: key -> NSRange
// Additional state inferred from context
NSInteger _baseOffset; // offset for non-chapter content
BOOL _containsIntroFlyleaf; // includes cover/flyleaf page
BOOL _indicesBuilt; // whether initIndices has been called
}
#pragma mark - Lifecycle
- (instancetype)initWithFilePaths:(NSArray<NSString *> *)filePaths
attributedStrings:(NSArray<NSAttributedString *> *)attributedStrings
offset:(NSInteger)offset
isContainIntroFlyleaf:(BOOL)isContainIntroFlyleaf
{
self = [super init];
if (self) {
_filePaths = [filePaths copy];
_attributedStrings = [attributedStrings copy];
_baseOffset = offset;
_containsIntroFlyleaf = isContainIntroFlyleaf;
_fileLengths = [NSMutableArray arrayWithCapacity:filePaths.count];
_cumulativeOffsets = [NSMutableArray arrayWithCapacity:filePaths.count];
_indexCache = [NSMutableDictionary dictionary];
_stringRangeCache = [NSMutableDictionary dictionary];
_indicesBuilt = NO;
}
return self;
}
#pragma mark - Index Building
- (void)initIndices
{
if (_indicesBuilt) return;
[_fileLengths removeAllObjects];
[_cumulativeOffsets removeAllObjects];
NSInteger cumulative = _baseOffset;
for (NSUInteger i = 0; i < _attributedStrings.count; i++) {
NSAttributedString *attrStr = _attributedStrings[i];
NSInteger length = (NSInteger)attrStr.string.length;
[_fileLengths addObject:@(length)];
// Cumulative offset = sum of lengths of all previous files + baseOffset
[_cumulativeOffsets addObject:@(cumulative)];
cumulative += length;
}
_indicesBuilt = YES;
}
#pragma mark - Position Conversion: Row/Column -> String Index
- (void)indicesInFile:(NSString *)filePath
forRowColumnPairs:(NSArray<WRPositionPair *> *)rowColumnPairs
stringIndices:(NSArray *_Nullable *_Nullable)stringIndices
string:(NSString *)string
fileIndexOffset:(NSInteger)fileIndexOffset
stringIndexOffset:(NSInteger)stringIndexOffset
{
// 1. Find the file index for the given path
NSInteger fileIndex = [_filePaths indexOfObject:filePath];
if (fileIndex == NSNotFound) {
// Try matching by last path component (filename only)
NSString *filename = [filePath lastPathComponent];
for (NSUInteger i = 0; i < _filePaths.count; i++) {
if ([[_filePaths[i] lastPathComponent] isEqualToString:filename]) {
fileIndex = (NSInteger)i;
break;
}
}
}
if (fileIndex == NSNotFound) {
if (stringIndices) *stringIndices = @[];
return;
}
fileIndex += fileIndexOffset;
// 2. Build a line-offset table from the string
//
// We need to map (row, column) -> character offset within the string.
// A "row" is a line number; "column" is the character position in that line.
//
// Strategy: scan the string and record the starting offset of each line.
NSMutableArray<NSNumber *> *lineStarts = [NSMutableArray array];
[lineStarts addObject:@(0)]; // line 0 starts at offset 0
NSUInteger len = string.length;
for (NSUInteger i = 0; i < len; i++) {
unichar c = [string characterAtIndex:i];
if (c == '\n' || c == '\r') {
// Handle \r\n as a single line ending
if (c == '\r' && i + 1 < len && [string characterAtIndex:i + 1] == '\n') {
i++; // skip the \n
}
if (i + 1 < len) {
[lineStarts addObject:@(i + 1)];
}
}
}
// 3. Convert each (row, column) pair to a string index
NSMutableArray<NSNumber *> *results =
[NSMutableArray arrayWithCapacity:rowColumnPairs.count];
for (WRPositionPair *pair in rowColumnPairs) {
NSInteger row = pair.row;
NSInteger column = pair.column;
if (row < 0 || row >= (NSInteger)lineStarts.count) {
[results addObject:@(NSNotFound)];
continue;
}
NSInteger lineStart = [lineStarts[row] integerValue];
// Find the end of this line
NSInteger lineEnd;
if (row + 1 < (NSInteger)lineStarts.count) {
lineEnd = [lineStarts[row + 1] integerValue] - 1;
} else {
lineEnd = (NSInteger)len;
}
NSInteger lineLength = lineEnd - lineStart;
if (column > lineLength) {
column = lineLength; // clamp to end of line
}
NSInteger stringIndex = lineStart + column + stringIndexOffset;
// Also add the cumulative offset for this file to get the global position
if (fileIndex >= 0 && fileIndex < (NSInteger)_cumulativeOffsets.count) {
stringIndex += [_cumulativeOffsets[fileIndex] integerValue];
}
[results addObject:@(stringIndex)];
}
if (stringIndices) {
*stringIndices = [results copy];
}
}
#pragma mark - Position Conversion: File Range -> String Range
- (NSRange)stringRangeFromFileRange:(NSDictionary *)fileRange
{
// Expected keys in fileRange:
// @"fileIndex" -> NSNumber (NSInteger)
// @"start" -> NSNumber (NSInteger) local offset within the file
// @"end" -> NSNumber (NSInteger) local offset within the file
NSNumber *fileIndexNum = fileRange[@"fileIndex"];
NSNumber *startNum = fileRange[@"start"];
NSNumber *endNum = fileRange[@"end"];
if (!fileIndexNum || !startNum || !endNum) {
return NSMakeRange(NSNotFound, 0);
}
NSInteger fileIndex = [fileIndexNum integerValue];
NSInteger localStart = [startNum integerValue];
NSInteger localEnd = [endNum integerValue];
// Check cache
NSString *cacheKey =
[NSString stringWithFormat:@"%ld:%ld:%ld", (long)fileIndex,
(long)localStart, (long)localEnd];
NSNumber *cached = _stringRangeCache[cacheKey];
if (cached) {
return NSRangeFromString(cached.stringValue);
}
// Validate file index
if (!_indicesBuilt) [self initIndices];
if (fileIndex < 0 || fileIndex >= (NSInteger)_cumulativeOffsets.count) {
return NSMakeRange(NSNotFound, 0);
}
// Get the cumulative offset for this file
NSInteger cumulativeOffset = [_cumulativeOffsets[fileIndex] integerValue];
// File-local length
NSInteger fileLength = [_fileLengths[fileIndex] integerValue];
// Clamp to file bounds
if (localStart < 0) localStart = 0;
if (localEnd > fileLength) localEnd = fileLength;
if (localStart >= localEnd) {
return NSMakeRange(NSNotFound, 0);
}
NSInteger globalStart = cumulativeOffset + localStart;
NSInteger length = localEnd - localStart;
NSRange result = NSMakeRange(globalStart, length);
// Cache the result
_stringRangeCache[cacheKey] = NSStringFromRange(result);
return result;
}
#pragma mark - Utility
- (NSInteger)totalCharacterCount
{
if (!_indicesBuilt) [self initIndices];
if (_cumulativeOffsets.count == 0) return _baseOffset;
NSInteger lastCumulative =
[_cumulativeOffsets.lastObject integerValue];
NSInteger lastLength = [_fileLengths.lastObject integerValue];
return lastCumulative + lastLength;
}
- (NSInteger)fileIndexForCharacterPosition:(NSInteger)position
{
if (!_indicesBuilt) [self initIndices];
// Binary search through cumulative offsets
NSInteger lo = 0;
NSInteger hi = (NSInteger)_cumulativeOffsets.count - 1;
NSInteger result = -1;
while (lo <= hi) {
NSInteger mid = lo + (hi - lo) / 2;
NSInteger offset = [_cumulativeOffsets[mid] integerValue];
if (offset <= position) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// Verify the position is within this file's range
if (result >= 0) {
NSInteger fileLen = [_fileLengths[result] integerValue];
NSInteger cumOff = [_cumulativeOffsets[result] integerValue];
if (position >= cumOff + fileLen) {
result = -1; // beyond end of last file
}
}
return result;
}
- (NSInteger)localOffsetInFileAtIndex:(NSInteger)fileIndex
forGlobalPosition:(NSInteger)position
{
if (!_indicesBuilt) [self initIndices];
if (fileIndex < 0 || fileIndex >= (NSInteger)_cumulativeOffsets.count) {
return NSNotFound;
}
NSInteger cumulativeOffset = [_cumulativeOffsets[fileIndex] integerValue];
NSInteger localOffset = position - cumulativeOffset;
NSInteger fileLen = [_fileLengths[fileIndex] integerValue];
if (localOffset < 0 || localOffset >= fileLen) {
return NSNotFound;
}
return localOffset;
}
@end
+127
View File
@@ -0,0 +1,127 @@
//
// WREpubTypesetter.h
// WeRead (微信读书) - Reverse Engineered
//
// EPUB Typesetter: Converts XHTML content into NSAttributedString
// via DTHTMLAttributedStringBuilder with CSS cascade, image handling,
// hyperlink processing, traditional/simplified Chinese conversion,
// and free-trial truncation support.
//
// All public methods are class methods (no instance instantiation required).
// The four instance variables likely serve as cached state for the CSS
// loading pipeline, though the binary exposes no instance methods.
//
#import <Foundation/Foundation.h>
@class WRBook;
@class WRChapter;
@class WRChapterData;
NS_ASSUME_NONNULL_BEGIN
#pragma mark - Error Reporting Constants
/// Keys used in the error-reason dictionary returned by the typesetter.
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorStyleFileNotFound;
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorTranslationStyleNotFound;
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorTranslationContentNotFound;
#pragma mark - Custom CSS Attribute Names
/// NSAttributedString attribute: vertical centering style for inline elements.
/// Value: NSNumber (integer). 2 = enable vertical center (used on images).
FOUNDATION_EXPORT NSString *const WREpubTypesetterVerticalCenterStyleAttribute;
/// "wr-vertical-center-style" in CSS land.
/// NSAttributedString attribute: page-relative positioning hint.
/// Value: NSNumber (integer) indicating page relationship.
FOUNDATION_EXPORT NSString *const WREpubTypesetterPageRelateAttribute;
/// "weread-page-relate" in CSS land.
#pragma mark - Page Flipping Style Enum
typedef NS_ENUM(NSInteger, WRPageFlippingStyle) {
WRPageFlippingStyleDefault = 0,
WRPageFlippingStyleSlide = 1,
WRPageFlippingStyleCurl = 2,
WRPageFlippingStyleNone = 3,
};
#pragma mark - WREpubTypesetter
@interface WREpubTypesetter : NSObject {
// Instance variables (no instance methods found in the binary;
// these likely cache transient state during a typesetting pass).
NSString *_currentCSS; // Aggregated CSS after cascade merge
NSString *_epubEmbeddedCSS; // CSS extracted from the EPUB book itself
NSString *_userSettingsCSS; // User-applied CSS overrides (font, line-height, theme)
NSArray *_imageURLs; // Collected image attachment URLs for lazy loading
}
#pragma mark - Primary Typesetting Entry Point
/// Convert an EPUB XHTML chapter file into a styled NSAttributedString.
///
/// This is the main entry point. It:
/// 1. Loads and merges CSS in priority order (see implementation).
/// 2. Feeds XHTML + merged CSS into DTHTMLAttributedStringBuilder.
/// 3. Walks the resulting element tree to patch images, links,
/// custom attributes, and apply user typographic preferences.
/// 4. Optionally truncates the output for free-trial preview.
///
/// @param filePath Absolute path to the .xhtml / .html file inside the EPUB.
/// @param priority Unused or internal scheduling priority (pass 0 for default).
/// @param insertArticleToolAttachment If YES, embeds an article-tool NSTextAttachment
/// at the end of the attributed string.
/// @param insertBookChapterToolAttachment If YES, embeds a book-chapter-tool
/// NSTextAttachment.
/// @param insertRecommendView If YES, appends a recommendation-view attachment.
/// @param book The WRBook model containing metadata, CSS paths, etc.
/// @param chapter The WRChapter model (chapter ID, title, ordering).
/// @param pageFlippingStyle Desired page-flip animation style.
/// @param renderErrorReason [out] On failure, set to a human-readable error description.
/// May be NULL if the caller does not need it.
/// @param isStyleFileNotFound [out] Set to YES if a required CSS file was missing.
/// May be NULL.
/// @param options Additional NSDictionary of rendering options (font family,
/// line-height multiplier, dark-mode flag, etc.).
///
/// @return A fully styled NSAttributedString, or nil on unrecoverable error.
+ (nullable NSAttributedString *)
attributeStringWithFilePath:(NSString *)filePath
priority:(NSInteger)priority
insertArticleToolAttachment:(BOOL)insertArticleToolAttachment
insertBookChapterToolAttachment:(BOOL)insertBookChapterToolAttachment
insertRecommendView:(BOOL)insertRecommendView
book:(WRBook *)book
chapter:(WRChapter *)chapter
pageFlippingStyle:(WRPageFlippingStyle)pageFlippingStyle
renderErrorReason:(NSString * _Nullable * _Nullable)renderErrorReason
isStyleFileNotFound:(BOOL * _Nullable)isStyleFileNotFound
options:(NSDictionary * _Nullable)options;
#pragma mark - Translation Error Reporting
/// Report a translation-related rendering error to the analytics subsystem.
///
/// Called when the typesetter detects missing translation CSS or content
/// during bilingual (original + translated) rendering.
///
/// @param error The underlying NSError, if any.
/// @param bookId Book identifier string.
/// @param chapter The WRChapter that failed translation rendering.
/// @param isTranslationStyleNotFound YES if the translation CSS file was missing.
/// @param isTranslationContentNotFound YES if the translated XHTML content was missing.
/// @param isTranslateTagButNoTranslateStyle YES if the HTML contained a translate tag
/// but no corresponding CSS rule was found.
+ (void)tryReportTranslationError:(nullable NSError *)error
bookId:(NSString *)bookId
chapter:(WRChapter *)chapter
isTranslationStyleNotFound:(BOOL)isTranslationStyleNotFound
isTranslationContentNotFound:(BOOL)isTranslationContentNotFound
isTranslateTagButNoTranslateStyle:(BOOL)isTranslateTagButNoTranslateStyle;
@end
NS_ASSUME_NONNULL_END
+794
View File
@@ -0,0 +1,794 @@
//
// WREpubTypesetter.m
// WeRead (微信读书) - Reverse Engineered Implementation Reconstruction
//
// This is a detailed pseudo-code reconstruction based on:
// - Binary strings output (method signatures, CSS filenames, attribute names)
// - Architecture documentation (CSS cascade order, processing pipeline)
// - DTCSSStylesheet / DTHTMLAttributedStringBuilder public API
// - Behavioral analysis of WeRead EPUB rendering
//
// Disclaimer: This is a reconstruction, not a decompiled original.
// Variable names, control flow, and helper methods are educated guesses
// informed by the above evidence.
//
#import "WREpubTypesetter.h"
// DTLite (DTCoreText) framework headers
#import "DTHTMLAttributedStringBuilder.h"
#import "DTHTMLElement.h"
#import "DTCoreTextFontDescriptor.h"
#import "DTCSSStylesheet.h"
#import "DTTextAttachment.h"
#import "DTLinkButton.h"
#import "DTColor.h"
// WeRead internal
#import "WRBook.h"
#import "WRChapter.h"
#import "WRChapterData.h"
#import "WRCoreTextLayouter.h"
#pragma mark - Constants
NSString *const WREpubTypesetterVerticalCenterStyleAttribute = @"wr-vertical-center-style";
NSString *const WREpubTypesetterPageRelateAttribute = @"weread-page-relate";
NSString *const WREpubTypesetterErrorStyleFileNotFound = @"WREpubTypesetterErrorStyleFileNotFound";
NSString *const WREpubTypesetterErrorTranslationStyleNotFound = @"WREpubTypesetterErrorTranslationStyleNotFound";
NSString *const WREpubTypesetterErrorTranslationContentNotFound = @"WREpubTypesetterErrorTranslationContentNotFound";
#pragma mark - File-Private Helpers (Forward Declarations)
/// Merge multiple CSS sources into a single DTCSSStylesheet in priority order.
static DTCSSStylesheet *_WRCascadeStylesheets(
NSString *defaultCSSPath,
NSString *replaceCSSPath,
NSString *darkCSSPath,
NSString *epubEmbeddedCSSString,
NSString *userSettingsCSSString
);
/// Load the contents of a CSS file from the app bundle or EPUB container.
/// Returns nil if the file does not exist; sets *outFound to NO.
static NSString *_WRLoadCSSFileAtPath(NSString *path, BOOL *outFound);
/// Convert simplified Chinese (Hans) to traditional Chinese (Hant) for
/// locales that require it (zh-Hant / zh-TW / zh-HK).
static NSString *_WRConvertHansToHantIfNeeded(NSString *htmlString, NSString *languageCode);
/// Build the user-settings CSS string from the options dictionary.
static NSString *_WRBuildUserSettingsCSS(NSDictionary *options);
/// Truncate the attributed string at the free-trial boundary.
/// Returns a sub-string ending at the last paragraph break before the limit.
static NSAttributedString *_WRTruncateForFreeTrial(
NSAttributedString *fullString,
NSUInteger maxCharacterCount
);
/// Walk the DTHTMLElement tree and patch custom attributes (vertical center,
/// page-relate), rewrite image attachments, and resolve hyperlinks.
static void _WRPostProcessElementTree(DTHTMLElement *root, NSDictionary *options);
/// Build the combined rendering options dict that DTHTMLAttributedStringBuilder expects.
static NSDictionary *_WRBuildDTOptions(
DTCSSStylesheet *stylesheet,
WRBook *book,
NSDictionary *userOptions
);
#pragma mark - Implementation
@implementation WREpubTypesetter
#pragma mark - Primary Typesetting Entry Point
+ (NSAttributedString *)
attributeStringWithFilePath:(NSString *)filePath
priority:(NSInteger)priority
insertArticleToolAttachment:(BOOL)insertArticleToolAttachment
insertBookChapterToolAttachment:(BOOL)insertBookChapterToolAttachment
insertRecommendView:(BOOL)insertRecommendView
book:(WRBook *)book
chapter:(WRChapter *)chapter
pageFlippingStyle:(WRPageFlippingStyle)pageFlippingStyle
renderErrorReason:(NSString * _Nullable * _Nullable)renderErrorReason
isStyleFileNotFound:(BOOL * _Nullable)isStyleFileNotFound
options:(NSDictionary * _Nullable)options
{
// ========================================================================
// Step 0: Guard — validate inputs
// ========================================================================
if (!filePath.length || !book || !chapter) {
if (renderErrorReason) {
*renderErrorReason = @"Invalid parameters: filePath, book, or chapter is nil.";
}
return nil;
}
// Default options to empty dict
NSDictionary *effectiveOptions = options ?: @{};
// ========================================================================
// Step 1: Load the XHTML source
// ========================================================================
//
// Read the raw XHTML/HTML from the EPUB file on disk.
// If the file is missing or unreadable, bail out early.
//
NSError *readError = nil;
NSData *htmlData = [NSData dataWithContentsOfFile:filePath
options:0
error:&readError];
if (!htmlData) {
if (renderErrorReason) {
*renderErrorReason = [NSString stringWithFormat:
@"Failed to read XHTML file at path: %@ — %@", filePath, readError.localizedDescription];
}
return nil;
}
// Detect encoding: EPUB 3 defaults to UTF-8; older EPUBs may use UTF-16.
// DTHTMLAttributedStringBuilder handles encoding detection, but we convert
// NSData → NSString here for the Hans-to-Hant step.
NSString *htmlString = [[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding];
if (!htmlString) {
// Fallback to ASCII lossy conversion
htmlString = [[NSString alloc] initWithData:htmlData
encoding:NSASCIIStringEncoding];
}
// ========================================================================
// Step 1.5: Traditional / Simplified Chinese conversion
// ========================================================================
//
// WeRead supports reading in Traditional Chinese even when the book
// is authored in Simplified. If the user's locale / book language is
// zh-Hant, convert all Simplified characters to Traditional.
//
NSString *bookLanguage = book.language ?: @"zh-Hans";
if ([bookLanguage containsString:@"Hant"] ||
[bookLanguage containsString:@"TW"] ||
[bookLanguage containsString:@"HK"])
{
htmlString = _WRConvertHansToHantIfNeeded(htmlString, bookLanguage);
}
// ========================================================================
// Step 2: Load and cascade CSS stylesheets
// ========================================================================
//
// CSS priority (lowest → highest, i.e., later sources override earlier):
// 1. default.css — Base HTML tag styles (body, p, h1-h6, ul, ol, etc.)
// 2. replace.css — WeRead's default replacements:
// • headings → Source Han Serif CN (思源宋体)
// • code blocks → Menlo
// • images → .bodyPic with wr-vertical-center-style:2
// 3. dark.css — Dark-theme color overrides (background, text color)
// 4. EPUB embedded — The book's own <style> blocks and linked CSS
// 5. User settings — Runtime user prefs (font size, line-height, theme)
//
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
NSString *defaultCSSPath = [bundlePath stringByAppendingPathComponent:@"default.css"];
NSString *replaceCSSPath = [bundlePath stringByAppendingPathComponent:@"replace.css"];
NSString *darkCSSPath = [bundlePath stringByAppendingPathComponent:@"dark.css"];
// Dark theme: only load dark.css if the user has selected dark mode.
BOOL isDarkMode = [effectiveOptions[@"darkMode"] boolValue];
if (!isDarkMode) {
darkCSSPath = nil; // skip dark.css
}
// EPUB embedded CSS: extracted from the book's <style> and <link> tags
// during EPUB parsing. Stored on WRChapterData or WRBook.
NSString *epubEmbeddedCSS = book.epubEmbeddedCSS ?: @"";
// User settings CSS: built from the user's runtime preferences.
NSString *userSettingsCSS = _WRBuildUserSettingsCSS(effectiveOptions);
// Cascade merge
DTCSSStylesheet *mergedStylesheet = _WRCascadeStylesheets(
defaultCSSPath,
replaceCSSPath,
darkCSSPath,
epubEmbeddedCSS,
userSettingsCSS
);
// Track whether any required CSS file was missing.
BOOL styleFileMissing = NO;
{
// Quick existence check — the loader already sets this, but we
// double-check for the primary stylesheet.
BOOL dummyFound = YES;
_WRLoadCSSFileAtPath(defaultCSSPath, &dummyFound);
if (!dummyFound) {
styleFileMissing = YES;
}
}
if (isStyleFileNotFound) {
*isStyleFileNotFound = styleFileMissing;
}
// ========================================================================
// Step 3: Configure DTHTMLAttributedStringBuilder
// ========================================================================
//
// DTHTMLAttributedStringBuilder is a third-party (DTCoreText / DTLite)
// library that parses HTML/CSS into a tree of DTHTMLElement nodes, then
// lays out the tree via CoreText to produce an NSAttributedString.
//
NSDictionary *dtOptions = _WRBuildDTOptions(mergedStylesheet, book, effectiveOptions);
DTHTMLAttributedStringBuilder *builder =
[[DTHTMLAttributedStringBuilder alloc] initWithHTML:[htmlString dataUsingEncoding:NSUTF8StringEncoding]
options:dtOptions
documentAttributes:nil];
// Wire up custom post-processing via the builder's delegate.
// DTHTMLAttributedStringBuilder fires willFlushCallback for each
// DTHTMLElement before it is converted to NSAttributedString runs.
[builder setWillFlushCallback:^(DTHTMLElement *element) {
_WRPostProcessElementTree(element, effectiveOptions);
}];
// ========================================================================
// Step 4: Build the attributed string
// ========================================================================
NSAttributedString *result = [builder generatedAttributedString];
if (!result) {
if (renderErrorReason) {
*renderErrorReason = @"DTHTMLAttributedStringBuilder returned nil — HTML parsing may have failed.";
}
return nil;
}
// ========================================================================
// Step 5: Free-trial truncation
// ========================================================================
//
// WeRead offers free preview of N characters per chapter. If the
// chapter exceeds the trial limit, truncate at the last paragraph
// boundary before the limit.
//
BOOL isFreeTrial = [effectiveOptions[@"freeTrial"] boolValue];
NSUInteger trialCharacterLimit = [effectiveOptions[@"trialCharacterLimit"] unsignedIntegerValue];
if (isFreeTrial && trialCharacterLimit > 0 && result.length > trialCharacterLimit) {
result = _WRTruncateForFreeTrial(result, trialCharacterLimit);
}
// ========================================================================
// Step 6: Append tool / recommendation attachments
// ========================================================================
//
// WeRead optionally appends interactive tool bars and recommendation
// views as NSTextAttachment objects at the end of the chapter.
//
NSMutableAttributedString *finalResult = [result mutableCopy];
if (insertArticleToolAttachment) {
// Create a zero-width text attachment that the layout engine will
// render as the article toolbar (highlight, note, share buttons).
NSTextAttachment *articleToolAttachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
// Attach a custom data payload so the rendering layer can identify it.
articleToolAttachment.userInfo = @{
@"type": @"articleTool",
@"chapterId": chapter.chapterId ?: @""
};
NSAttributedString *attachmentStr =
[NSAttributedString attributedStringWithAttachment:articleToolAttachment];
[finalResult appendAttributedString:attachmentStr];
}
if (insertBookChapterToolAttachment) {
NSTextAttachment *chapterToolAttachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
chapterToolAttachment.userInfo = @{
@"type": @"bookChapterTool",
@"chapterId": chapter.chapterId ?: @""
};
[finalResult appendAttributedString:
[NSAttributedString attributedStringWithAttachment:chapterToolAttachment]];
}
if (insertRecommendView) {
NSTextAttachment *recommendAttachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
recommendAttachment.userInfo = @{
@"type": @"recommendView",
@"bookId": book.bookId ?: @""
};
[finalResult appendAttributedString:
[NSAttributedString attributedStringWithAttachment:recommendAttachment]];
}
return [finalResult copy];
}
#pragma mark - Translation Error Reporting
+ (void)tryReportTranslationError:(nullable NSError *)error
bookId:(NSString *)bookId
chapter:(WRChapter *)chapter
isTranslationStyleNotFound:(BOOL)isTranslationStyleNotFound
isTranslationContentNotFound:(BOOL)isTranslationContentNotFound
isTranslateTagButNoTranslateStyle:(BOOL)isTranslateTagButNoTranslateStyle
{
// ========================================================================
// Build a structured analytics event and fire it via WeRead's telemetry.
//
// Translation errors occur when the user requests bilingual mode
// (original + translated text) but the translation data or CSS is
// missing from the EPUB package.
// ========================================================================
// Only report if at least one error flag is set.
if (!isTranslationStyleNotFound &&
!isTranslationContentNotFound &&
!isTranslateTagButNoTranslateStyle)
{
return;
}
NSMutableDictionary *eventPayload = [NSMutableDictionary dictionary];
eventPayload[@"bookId"] = bookId ?: @"";
eventPayload[@"chapterId"] = chapter.chapterId ?: @"";
eventPayload[@"chapterTitle"] = chapter.title ?: @"";
eventPayload[@"isTranslationStyleNotFound"] = @(isTranslationStyleNotFound);
eventPayload[@"isTranslationContentNotFound"] = @(isTranslationContentNotFound);
eventPayload[@"isTranslateTagButNoTranslateStyle"] = @(isTranslateTagButNoTranslateStyle);
if (error) {
eventPayload[@"errorCode"] = @(error.code);
eventPayload[@"errorDomain"] = error.domain ?: @"";
eventPayload[@"errorMessage"] = error.localizedDescription ?: @"";
}
// WeRead uses a custom telemetry SDK (likely based on Tencent's MTA or
// a proprietary solution). The event name is likely "epub_translation_error".
//
// [WRAnalytics reportEvent:@"epub_translation_error" params:eventPayload];
NSLog(@"[WREpubTypesetter] Translation error for book %@ chapter %@: %@",
bookId, chapter.chapterId, eventPayload);
}
@end
#pragma mark - File-Private Helper Implementations
// ============================================================================
// _WRCascadeStylesheets
// ============================================================================
//
// Merge multiple CSS sources into a single DTCSSStylesheet.
// Later sources override earlier ones — this is the "cascade" in CSS.
//
static DTCSSStylesheet *_WRCascadeStylesheets(
NSString *defaultCSSPath,
NSString *replaceCSSPath,
NSString *darkCSSPath,
NSString *epubEmbeddedCSSString,
NSString *userSettingsCSSString)
{
DTCSSStylesheet *merged = [[DTCSSStylesheet alloc] init];
// --- Layer 1: default.css (base HTML tag styles) ---
//
// This file defines how standard HTML elements look:
// body { font-family: ...; margin: 0; }
// p { margin-top: 0.5em; margin-bottom: 0.5em; }
// h1 { font-size: 1.8em; font-weight: bold; }
// ... etc.
//
if (defaultCSSPath) {
NSString *css = _WRLoadCSSFileAtPath(defaultCSSPath, NULL);
if (css) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
[merged mergeStylesheet:layer];
}
}
// --- Layer 2: replace.css (WeRead's default replacements) ---
//
// Overrides specific elements with WeRead's preferred styling:
// h1, h2, h3 { font-family: "Source Han Serif CN", serif; }
// pre, code { font-family: "Menlo", monospace; }
// img.bodyPic { wr-vertical-center-style: 2; max-width: 100%; }
//
// This ensures a consistent reading experience across different EPUBs.
//
if (replaceCSSPath) {
NSString *css = _WRLoadCSSFileAtPath(replaceCSSPath, NULL);
if (css) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
[merged mergeStylesheet:layer];
}
}
// --- Layer 3: dark.css (dark theme overrides) ---
//
// Only loaded when the user is in dark / night mode:
// body { background-color: #1a1a1a; color: #cccccc; }
// a { color: #6eaad7; }
// img { filter: brightness(0.85); }
//
if (darkCSSPath) {
NSString *css = _WRLoadCSSFileAtPath(darkCSSPath, NULL);
if (css) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
[merged mergeStylesheet:layer];
}
}
// --- Layer 4: EPUB embedded CSS ---
//
// The book author's own styles, extracted from <style> blocks and
// linked .css files inside the EPUB container.
//
if (epubEmbeddedCSSString.length) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:epubEmbeddedCSSString];
[merged mergeStylesheet:layer];
}
// --- Layer 5: User settings CSS ---
//
// Dynamically generated from the user's runtime preferences:
// body {
// font-size: 18px;
// line-height: 1.8;
// font-family: "PingFang SC", sans-serif;
// background-color: #f5f0e8; (sepia theme)
// }
//
if (userSettingsCSSString.length) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:userSettingsCSSString];
[merged mergeStylesheet:layer];
}
return merged;
}
// ============================================================================
// _WRLoadCSSFileAtPath
// ============================================================================
//
// Read a CSS file from disk. Returns the string contents, or nil if
// the file does not exist. Optionally reports existence via outFound.
//
static NSString *_WRLoadCSSFileAtPath(NSString *path, BOOL *outFound)
{
if (!path.length) {
if (outFound) *outFound = NO;
return nil;
}
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:path]) {
if (outFound) *outFound = NO;
return nil;
}
if (outFound) *outFound = YES;
NSError *error = nil;
NSString *contents = [NSString stringWithContentsOfFile:path
encoding:NSUTF8StringEncoding
error:&error];
if (!contents) {
NSLog(@"[WREpubTypesetter] Failed to load CSS at %@: %@", path, error.localizedDescription);
}
return contents;
}
// ============================================================================
// _WRConvertHansToHantIfNeeded
// ============================================================================
//
// Convert Simplified Chinese characters to Traditional Chinese.
// WeRead supports bilingual mode and locale-based character conversion.
//
// The actual conversion likely uses Apple's CFStringTransform with
// kCFStringTransformToLatin + kCFStringTransformLatinHant, or a
// custom lookup table for high-fidelity conversion.
//
static NSString *_WRConvertHansToHantIfNeeded(NSString *htmlString, NSString *languageCode)
{
if (!htmlString.length) return htmlString;
// CFStringTransform approach (simplified):
// 1. Convert Hans → Latin (pinyin)
// 2. Convert Latin → Hant (traditional)
//
// This is lossy for some characters. WeRead may use a custom
// dictionary (OpenCC or similar) for better accuracy.
//
NSMutableString *mutable = [htmlString mutableCopy];
// Apple's built-in transliteration:
// Hans → Hant is not directly supported via CFStringTransform,
// so a two-step Latin bridge is used.
//
CFStringRef str = (__bridge CFStringRef)mutable;
CFStringTransform(str, NULL, kCFStringTransformToLatin, NO);
CFStringTransform(str, NULL, kCFStringTransformLatinHant, NO);
return [mutable copy];
}
// ============================================================================
// _WRBuildUserSettingsCSS
// ============================================================================
//
// Build a CSS string from the user's runtime preferences dictionary.
// These override any CSS from the book or WeRead defaults.
//
static NSString *_WRBuildUserSettingsCSS(NSDictionary *options)
{
if (!options.count) return @"";
NSMutableString *css = [NSMutableString string];
// Font family
NSString *fontFamily = options[@"fontFamily"];
if (fontFamily.length) {
[css appendFormat:@"body { font-family: \"%@\", sans-serif; }\n", fontFamily];
}
// Font size
NSNumber *fontSize = options[@"fontSize"];
if (fontSize) {
[css appendFormat:@"body { font-size: %ldpx; }\n", (long)fontSize.integerValue];
}
// Line height
NSNumber *lineHeight = options[@"lineHeight"];
if (lineHeight) {
[css appendFormat:@"body { line-height: %.2f; }\n", lineHeight.floatValue];
}
// Letter spacing (character spacing)
NSNumber *letterSpacing = options[@"letterSpacing"];
if (letterSpacing) {
[css appendFormat:@"body { letter-spacing: %ldpx; }\n", (long)letterSpacing.integerValue];
}
// Theme background color
NSString *backgroundColor = options[@"backgroundColor"];
if (backgroundColor.length) {
[css appendFormat:@"body { background-color: %@; }\n", backgroundColor];
}
// Theme text color
NSString *textColor = options[@"textColor"];
if (textColor.length) {
[css appendFormat:@"body, p, span, div { color: %@; }\n", textColor];
}
// Paragraph margin / first-line indent
NSNumber *paragraphSpacing = options[@"paragraphSpacing"];
if (paragraphSpacing) {
[css appendFormat:@"p { margin-top: %ldpx; margin-bottom: %ldpx; }\n",
(long)paragraphSpacing.integerValue,
(long)paragraphSpacing.integerValue];
}
NSNumber *firstLineIndent = options[@"firstLineIndent"];
if (firstLineIndent) {
[css appendFormat:@"p { text-indent: %ldpx; }\n",
(long)firstLineIndent.integerValue];
}
return [css copy];
}
// ============================================================================
// _WRBuildDTOptions
// ============================================================================
//
// Build the options dictionary for DTHTMLAttributedStringBuilder.
//
static NSDictionary *_WRBuildDTOptions(
DTCSSStylesheet *stylesheet,
WRBook *book,
NSDictionary *userOptions)
{
NSMutableDictionary *opts = [NSMutableDictionary dictionary];
// Base URL for resolving relative image paths inside the EPUB.
// This is the directory containing the XHTML file.
if (book.epubBasePath) {
opts[DTBaseURL] = [NSURL fileURLWithPath:book.epubBasePath];
}
// Apply the merged CSS stylesheet
if (stylesheet) {
opts[DTDefaultStylesheet] = stylesheet;
}
// CoreText font descriptor for the default body font.
// WeRead uses system fonts (PingFang SC) or user-chosen fonts.
DTCoreTextFontDescriptor *fontDesc = [[DTCoreTextFontDescriptor alloc] init];
NSString *userFont = userOptions[@"fontFamily"];
if (userFont.length) {
fontDesc.fontName = userFont;
} else {
fontDesc.fontName = @"PingFang SC";
}
NSNumber *fontSize = userOptions[@"fontSize"];
fontDesc.pointSize = fontSize ? fontSize.floatValue : 18.0f;
opts[DTDefaultFontDescriptor] = fontDesc;
// Text color
opts[DTDefaultTextColor] = [DTColor blackColor];
// Link color
opts[DTDefaultLinkColor] = [DTColor colorWithRed:0.0 green:0.478 blue:1.0 alpha:1.0];
// Disable image downloading — WeRead handles images locally from the EPUB.
opts[DTIgnoreInlineStyles] = @NO;
opts[DTMaxImageSize] = @(CGSizeMake(1080, 1920)); // Max display size
return [opts copy];
}
// ============================================================================
// _WRPostProcessElementTree
// ============================================================================
//
// Walk the DTHTMLElement tree before it is flushed to NSAttributedString
// and apply WeRead-specific patches:
//
// 1. Images → wrap in NSTextAttachment with .bodyPic class, apply
// wr-vertical-center-style:2 so images center vertically.
//
// 2. Hyperlinks → attach DTLinkButton metadata, set link color.
//
// 3. Custom attributes:
// - wr-vertical-center-style: element is vertically centered
// - weread-page-relate: element has page-relative positioning
//
// 4. Font patching: if the book specifies a custom font, override
// font descriptors in heading and paragraph nodes.
//
static void _WRPostProcessElementTree(DTHTMLElement *element, NSDictionary *options)
{
if (!element) return;
// --- Image handling ---
//
// DTHTMLElement nodes with a textAttachment represent <img> tags.
// WeRead patches image attachments to:
// - Set a maximum display size
// - Add the "bodyPic" CSS class
// - Apply vertical centering (wr-vertical-center-style:2)
//
if (element.textAttachment) {
DTTextAttachment *attachment = element.textAttachment;
// Enforce max image dimensions from user options or defaults.
CGSize maxSize = CGSizeMake(1080, 1920);
NSNumber *maxW = options[@"maxImageWidth"];
if (maxW) maxSize.width = maxW.floatValue;
if (attachment.originalSize.width > maxSize.width) {
CGFloat scale = maxSize.width / attachment.originalSize.width;
attachment.displaySize = CGSizeMake(
attachment.originalSize.width * scale,
attachment.originalSize.height * scale
);
}
// Mark as vertically centered (matches replace.css rule:
// img.bodyPic { wr-vertical-center-style: 2; })
//
// The custom attribute "wr-vertical-center-style" is read by
// WRCoreTextLayouter during line layout to adjust the baseline
// offset so the image sits vertically centered on the line.
//
element.textAttachment.attributes = @{
WREpubTypesetterVerticalCenterStyleAttribute: @(2)
};
// Add "bodyPic" CSS class for styling consistency.
[element addClass:@"bodyPic"];
}
// --- Hyperlink handling ---
//
// <a href="..."> tags produce DTHTMLElement nodes with a link.
// WeRead stores the link URL in the element's attribute dictionary
// so that WRCoreTextLayouter can render a tappable link.
//
NSString *linkURL = element.link;
if (linkURL.length) {
// Internal EPUB links (e.g., "#footnote-1") are resolved relative
// to the current file. External links open in a browser.
//
// Store the URL as a custom attribute that the rendering layer
// will pick up.
element.fontDescriptor.underlineTrait = YES;
}
// --- Custom CSS attributes ---
//
// Scan the element's style dictionary for WeRead-specific properties.
//
NSString *verticalCenter = element.styleAttributes[WREpubTypesetterVerticalCenterStyleAttribute];
if (verticalCenter) {
// Store as an NSNumber on the element for the layout engine.
// The value "2" means "center the element on the line."
element.textAttachment.attributes = @{
WREpubTypesetterVerticalCenterStyleAttribute: @([verticalCenter integerValue])
};
}
NSString *pageRelate = element.styleAttributes[WREpubTypesetterPageRelateAttribute];
if (pageRelate) {
// Page-relative positioning: used for elements that should
// appear at a fixed position relative to the page (e.g., headers).
}
// --- Recurse into children ---
//
for (DTHTMLElement *child in element.childNodes) {
_WRPostProcessElementTree(child, options);
}
}
// ============================================================================
// _WRTruncateForFreeTrial
// ============================================================================
//
// Truncate an NSAttributedString at the free-trial boundary.
//
// WeRead lets non-VIP users read a limited number of characters per
// chapter. The truncation point is the last paragraph break (\n or
// NSParagraphSeparator) before the character limit.
//
static NSAttributedString *_WRTruncateForFreeTrial(
NSAttributedString *fullString,
NSUInteger maxCharacterCount)
{
if (fullString.length <= maxCharacterCount) {
return fullString;
}
// Walk backwards from the limit to find a paragraph boundary.
NSString *plainText = fullString.string;
NSUInteger truncateAt = maxCharacterCount;
while (truncateAt > 0) {
unichar c = [plainText characterAtIndex:truncateAt - 1];
if (c == '\n' || c == 0x2029 /* NSParagraphSeparator */) {
break;
}
truncateAt--;
}
// If we couldn't find a paragraph break, just cut at the limit.
if (truncateAt == 0) {
truncateAt = maxCharacterCount;
}
// Create a sub-attributed-string up to the truncation point.
NSRange range = NSMakeRange(0, truncateAt);
NSMutableAttributedString *truncated =
[[fullString attributedSubstringFromRange:range] mutableCopy];
// Append a "..." indicator so the reader knows the chapter continues.
NSDictionary *lastAttributes = [truncated attributesAtIndex:truncated.length - 1
effectiveRange:NULL];
NSAttributedString *ellipsis =
[[NSAttributedString alloc] initWithString:@"\n\n...\n\n"
attributes:lastAttributes];
[truncated appendAttributedString:ellipsis];
return [truncated copy];
}
+67
View File
@@ -0,0 +1,67 @@
//
// WRPageHighlight.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Annotation model for text highlights. Stores the highlighted text range,
// color, and associated metadata. 3 methods identified from binary.
//
// Inherits from or relates to WRPageMark (base class).
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WRBookmark;
// ---------------------------------------------------------------------------
// Highlight color presets
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRHighlightColor) {
WRHighlightColorYellow = 0,
WRHighlightColorBlue = 1,
WRHighlightColorRed = 2,
WRHighlightColorGreen = 3,
WRHighlightColorPurple = 4,
};
// ---------------------------------------------------------------------------
// WRPageHighlight
// ---------------------------------------------------------------------------
@interface WRPageHighlight : NSObject
@property (nonatomic, copy) NSString *highlightId; // unique ID
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, copy) NSString *chapterUid;
@property (nonatomic, assign) NSInteger startPos; // global start position
@property (nonatomic, assign) NSInteger endPos; // global end position
@property (nonatomic, assign) NSInteger chapterOffset; // offset within chapter
@property (nonatomic, copy) NSString *markedText; // the highlighted text
@property (nonatomic, assign) WRHighlightColor color; // highlight color enum
@property (nonatomic, copy, nullable) NSString *colorStyle; // color name string
@property (nonatomic, assign) NSInteger pageIndex; // page in the reader
@property (nonatomic, assign) NSTimeInterval createTime;
@property (nonatomic, assign) BOOL isSynced;
#pragma mark - Methods (3 identified from binary)
/// Initialize a highlight with the given range and color.
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
color:(WRHighlightColor)color;
/// Convert this highlight to a WRBookmark for persistence/sync.
- (WRBookmark *)toBookmark;
/// Return the highlight color as a UIColor for rendering.
- (UIColor *)uiColor;
@end
NS_ASSUME_NONNULL_END
+105
View File
@@ -0,0 +1,105 @@
//
// WRPageHighlight.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 3 methods identified from binary. WRPageHighlight is a lightweight
// model for text highlights on a page.
//
#import "WRPageHighlight.h"
#import "WRBookmark.h"
// ---------------------------------------------------------------------------
// Color utility
// ---------------------------------------------------------------------------
static UIColor *UIColorForHighlightColor(WRHighlightColor color)
{
switch (color) {
case WRHighlightColorYellow:
return [UIColor colorWithRed:1.0 green:0.92 blue:0.23 alpha:0.35];
case WRHighlightColorBlue:
return [UIColor colorWithRed:0.26 green:0.65 blue:0.96 alpha:0.35];
case WRHighlightColorRed:
return [UIColor colorWithRed:0.96 green:0.26 blue:0.26 alpha:0.35];
case WRHighlightColorGreen:
return [UIColor colorWithRed:0.30 green:0.85 blue:0.39 alpha:0.35];
case WRHighlightColorPurple:
return [UIColor colorWithRed:0.67 green:0.33 blue:0.97 alpha:0.35];
default:
return [UIColor colorWithRed:1.0 green:0.92 blue:0.23 alpha:0.35];
}
}
static NSString *NSStringFromHighlightColor(WRHighlightColor color)
{
switch (color) {
case WRHighlightColorYellow: return @"yellow";
case WRHighlightColorBlue: return @"blue";
case WRHighlightColorRed: return @"red";
case WRHighlightColorGreen: return @"green";
case WRHighlightColorPurple: return @"purple";
default: return @"yellow";
}
}
#pragma mark - Implementation
@implementation WRPageHighlight
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
color:(WRHighlightColor)color
{
self = [super init];
if (self) {
_highlightId = [[NSUUID UUID] UUIDString];
_bookId = [bookId copy];
_chapterUid = [chapterUid copy];
_startPos = startPos;
_endPos = endPos;
_chapterOffset = startPos; // Simplified; real impl maps global->local
_markedText = [text copy];
_color = color;
_colorStyle = NSStringFromHighlightColor(color);
_createTime = [[NSDate date] timeIntervalSince1970];
_isSynced = NO;
}
return self;
}
- (WRBookmark *)toBookmark
{
WRBookmark *bookmark = [WRBookmark highlightWithBookId:_bookId
chapterUid:_chapterUid
startPos:_startPos
endPos:_endPos
text:_markedText
colorStyle:_colorStyle];
bookmark.bookmarkId = _highlightId;
bookmark.chapterOffset = _chapterOffset;
bookmark.pageIndex = _pageIndex;
bookmark.createTime = _createTime;
bookmark.isSynced = _isSynced;
return bookmark;
}
- (UIColor *)uiColor
{
return UIColorForHighlightColor(_color);
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:@"<WRPageHighlight: %@ [%@] '%@'>",
_highlightId, _colorStyle,
[_markedText substringToIndex:MIN(40, _markedText.length)]];
}
@end
+139
View File
@@ -0,0 +1,139 @@
//
// WRPageMark.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Annotation model for page bookmarks/marks (dog-ear style).
// 25 methods identified from binary. The most feature-rich annotation type.
// Supports bookmark management, sorting, filtering, and display.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WRBookmark;
@class WRPageHighlight;
@class WRPageUnderline;
// ---------------------------------------------------------------------------
// Mark display mode
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRPageMarkDisplayMode) {
WRPageMarkDisplayModeIcon = 0, // Show bookmark icon
WRPageMarkDisplayModeList = 1, // Show in list view
WRPageMarkDisplayModeInline = 2, // Show inline in text
};
// ---------------------------------------------------------------------------
// WRPageMark
// ---------------------------------------------------------------------------
@interface WRPageMark : NSObject
@property (nonatomic, copy) NSString *markId;
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, copy) NSString *chapterUid;
@property (nonatomic, assign) NSInteger chapterOffset;
@property (nonatomic, assign) NSInteger pageIndex;
@property (nonatomic, copy) NSString *chapterTitle; // display title
@property (nonatomic, copy, nullable) NSString *excerptText; // text near the mark
@property (nonatomic, assign) NSTimeInterval createTime;
@property (nonatomic, assign) NSTimeInterval updateTime;
@property (nonatomic, assign) BOOL isSynced;
@property (nonatomic, copy, nullable) NSString *syncKey;
// Color/style for the mark icon
@property (nonatomic, strong, nullable) UIColor *markColor;
@property (nonatomic, assign) WRPageMarkDisplayMode displayMode;
// Linked annotations at the same position
@property (nonatomic, strong, nullable) NSArray<WRPageHighlight *> *linkedHighlights;
@property (nonatomic, strong, nullable) NSArray<WRPageUnderline *> *linkedUnderlines;
#pragma mark - Initialization
/// Create a page mark at the given position.
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
chapterOffset:(NSInteger)offset
pageIndex:(NSInteger)pageIndex
chapterTitle:(NSString *)title;
#pragma mark - Conversion (25 methods, reconstructed)
/// Convert to WRBookmark for persistence.
- (WRBookmark *)toBookmark;
/// Create from WRBookmark.
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark;
#pragma mark - Linked Annotations
/// Add a highlight linked to this mark.
- (void)addLinkedHighlight:(WRPageHighlight *)highlight;
/// Add an underline linked to this mark.
- (void)addLinkedUnderline:(WRPageUnderline *)underline;
/// Remove a linked highlight by ID.
- (void)removeLinkedHighlightWithId:(NSString *)highlightId;
/// Remove a linked underline by ID.
- (void)removeLinkedUnderlineWithId:(NSString *)underlineId;
/// Return all linked annotation IDs.
- (NSArray<NSString *> *)allLinkedAnnotationIds;
#pragma mark - Display
/// Return the title for display in the bookmark list.
- (NSString *)displayTitle;
/// Return the subtitle/detail text.
- (NSString *)displaySubtitle;
/// Return a formatted date string.
- (NSString *)formattedDate;
/// Return the mark icon image.
- (UIImage *)markIcon;
#pragma mark - Sorting & Filtering
/// Compare marks by position (for sorting in reading order).
- (NSComparisonResult)compareByPosition:(WRPageMark *)other;
/// Compare marks by creation date.
- (NSComparisonResult)compareByDate:(WRPageMark *)other;
/// Check if this mark is in the given chapter.
- (BOOL)isInChapter:(NSString *)chapterUid;
#pragma mark - Serialization
- (NSDictionary *)toDictionary;
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
- (nullable NSData *)toJSONData;
+ (nullable instancetype)fromJSONData:(NSData *)data;
#pragma mark - Batch Operations (class methods)
/// Sort an array of marks by position.
+ (NSArray<WRPageMark *> *)marksSortedByPosition:(NSArray<WRPageMark *> *)marks;
/// Sort an array of marks by date.
+ (NSArray<WRPageMark *> *)marksSortedByDate:(NSArray<WRPageMark *> *)marks;
/// Filter marks for a specific chapter.
+ (NSArray<WRPageMark *> *)marksInChapter:(NSString *)chapterUid
fromMarks:(NSArray<WRPageMark *> *)marks;
/// Filter marks within a position range.
+ (NSArray<WRPageMark *> *)marksInRange:(NSRange)range
fromMarks:(NSArray<WRPageMark *> *)marks;
@end
NS_ASSUME_NONNULL_END
+381
View File
@@ -0,0 +1,381 @@
//
// WRPageMark.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 25 methods identified from binary. WRPageMark is the most comprehensive
// annotation model, handling page bookmarks with linked highlights
// and underlines.
//
#import "WRPageMark.h"
#import "WRBookmark.h"
#import "WRPageHighlight.h"
#import "WRPageUnderline.h"
#pragma mark - Implementation
@implementation WRPageMark
{
NSMutableArray<WRPageHighlight *> *_mutableHighlights;
NSMutableArray<WRPageUnderline *> *_mutableUnderlines;
}
#pragma mark - Initialization
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
chapterOffset:(NSInteger)offset
pageIndex:(NSInteger)pageIndex
chapterTitle:(NSString *)title
{
self = [super init];
if (self) {
_markId = [[NSUUID UUID] UUIDString];
_bookId = [bookId copy];
_chapterUid = [chapterUid copy];
_chapterOffset = offset;
_pageIndex = pageIndex;
_chapterTitle = [title copy];
_createTime = [[NSDate date] timeIntervalSince1970];
_updateTime = _createTime;
_isSynced = NO;
_displayMode = WRPageMarkDisplayModeIcon;
_markColor = [UIColor colorWithRed:0.9 green:0.3 blue:0.2 alpha:1.0];
_mutableHighlights = [NSMutableArray array];
_mutableUnderlines = [NSMutableArray array];
_linkedHighlights = @[];
_linkedUnderlines = @[];
}
return self;
}
#pragma mark - Conversion
- (WRBookmark *)toBookmark
{
WRBookmark *bm = [WRBookmark bookmarkWithBookId:_bookId
chapterUid:_chapterUid
offset:_chapterOffset
text:_excerptText ?: @""
type:WRBookmarkTypeMark];
bm.bookmarkId = _markId;
bm.chapterIndex = _pageIndex;
bm.chapterOffset = _chapterOffset;
bm.createTime = _createTime;
bm.updateTime = _updateTime;
bm.isSynced = _isSynced;
bm.syncKey = _syncKey;
// Store linked annotation info in extra metadata
NSMutableDictionary *meta = [NSMutableDictionary dictionary];
if (_linkedHighlights.count > 0) {
NSMutableArray *ids = [NSMutableArray array];
for (WRPageHighlight *h in _linkedHighlights) {
[ids addObject:h.highlightId];
}
meta[@"linkedHighlightIds"] = ids;
}
if (_linkedUnderlines.count > 0) {
NSMutableArray *ids = [NSMutableArray array];
for (WRPageUnderline *u in _linkedUnderlines) {
[ids addObject:u.underlineId];
}
meta[@"linkedUnderlineIds"] = ids;
}
bm.extraMetadata = meta;
return bm;
}
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark
{
if (!bookmark || bookmark.type != WRBookmarkTypeMark) return nil;
WRPageMark *mark = [[WRPageMark alloc]
initWithBookId:bookmark.bookId
chapterUid:bookmark.chapterUid
chapterOffset:bookmark.chapterOffset
pageIndex:bookmark.chapterIndex
chapterTitle:@""]; // Title resolved separately
mark.markId = bookmark.bookmarkId;
mark.excerptText = bookmark.markText;
mark.createTime = bookmark.createTime;
mark.updateTime = bookmark.updateTime;
mark.isSynced = bookmark.isSynced;
mark.syncKey = bookmark.syncKey;
return mark;
}
#pragma mark - Linked Annotations
- (void)addLinkedHighlight:(WRPageHighlight *)highlight
{
if (!highlight) return;
[_mutableHighlights addObject:highlight];
_linkedHighlights = [_mutableHighlights copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (void)addLinkedUnderline:(WRPageUnderline *)underline
{
if (!underline) return;
[_mutableUnderlines addObject:underline];
_linkedUnderlines = [_mutableUnderlines copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (void)removeLinkedHighlightWithId:(NSString *)highlightId
{
[_mutableHighlights filterUsingPredicate:
[NSPredicate predicateWithFormat:@"highlightId != %@", highlightId]];
_linkedHighlights = [_mutableHighlights copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (void)removeLinkedUnderlineWithId:(NSString *)underlineId
{
[_mutableUnderlines filterUsingPredicate:
[NSPredicate predicateWithFormat:@"underlineId != %@", underlineId]];
_linkedUnderlines = [_mutableUnderlines copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (NSArray<NSString *> *)allLinkedAnnotationIds
{
NSMutableArray *ids = [NSMutableArray array];
for (WRPageHighlight *h in _linkedHighlights) {
[ids addObject:h.highlightId];
}
for (WRPageUnderline *u in _linkedUnderlines) {
[ids addObject:u.underlineId];
}
return [ids copy];
}
#pragma mark - Display
- (NSString *)displayTitle
{
if (_chapterTitle.length > 0) {
return _chapterTitle;
}
if (_excerptText.length > 0) {
return [_excerptText substringToIndex:MIN(50, _excerptText.length)];
}
return [NSString stringWithFormat:@"Page %ld", (long)_pageIndex];
}
- (NSString *)displaySubtitle
{
NSMutableArray *parts = [NSMutableArray array];
if (_linkedHighlights.count > 0) {
[parts addObject:[NSString stringWithFormat:@"%lu highlights",
(unsigned long)_linkedHighlights.count]];
}
if (_linkedUnderlines.count > 0) {
[parts addObject:[NSString stringWithFormat:@"%lu underlines",
(unsigned long)_linkedUnderlines.count]];
}
[parts addObject:[self formattedDate]];
return [parts componentsJoinedByString:@" | "];
}
- (NSString *)formattedDate
{
NSDate *date = [NSDate dateWithTimeIntervalSince1970:_createTime];
NSDateFormatter *fmt = [[NSDateFormatter alloc] init];
fmt.dateFormat = @"yyyy-MM-dd HH:mm";
return [fmt stringFromDate:date];
}
- (UIImage *)markIcon
{
// Generate a bookmark icon image programmatically
CGSize size = CGSizeMake(24, 32);
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
UIColor *color = _markColor ?: [UIColor redColor];
[color setFill];
// Draw a bookmark/flag shape
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(0, 0)];
[path addLineToPoint:CGPointMake(size.width, 0)];
[path addLineToPoint:CGPointMake(size.width, size.height - 6)];
[path addLineToPoint:CGPointMake(size.width / 2, size.height - 12)];
[path addLineToPoint:CGPointMake(0, size.height - 6)];
[path closePath];
[path fill];
UIImage *icon = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return icon;
}
#pragma mark - Sorting & Filtering
- (NSComparisonResult)compareByPosition:(WRPageMark *)other
{
if (![_chapterUid isEqualToString:other.chapterUid]) {
// Compare by chapter order (would need chapter index lookup)
return [_chapterUid compare:other.chapterUid];
}
if (_chapterOffset < other.chapterOffset) return NSOrderedAscending;
if (_chapterOffset > other.chapterOffset) return NSOrderedDescending;
return NSOrderedSame;
}
- (NSComparisonResult)compareByDate:(WRPageMark *)other
{
if (_createTime < other.createTime) return NSOrderedAscending;
if (_createTime > other.createTime) return NSOrderedDescending;
return NSOrderedSame;
}
- (BOOL)isInChapter:(NSString *)chapterUid
{
return [_chapterUid isEqualToString:chapterUid];
}
#pragma mark - Serialization
- (NSDictionary *)toDictionary
{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
dict[@"markId"] = _markId ?: @"";
dict[@"bookId"] = _bookId ?: @"";
dict[@"chapterUid"] = _chapterUid ?: @"";
dict[@"chapterOffset"] = @(_chapterOffset);
dict[@"pageIndex"] = @(_pageIndex);
dict[@"chapterTitle"] = _chapterTitle ?: @"";
dict[@"excerptText"] = _excerptText ?: @"";
dict[@"createTime"] = @(_createTime);
dict[@"updateTime"] = @(_updateTime);
dict[@"isSynced"] = @(_isSynced);
if (_syncKey) dict[@"syncKey"] = _syncKey;
dict[@"displayMode"] = @(_displayMode);
if (_linkedHighlights.count > 0) {
NSMutableArray *highlights = [NSMutableArray array];
for (WRPageHighlight *h in _linkedHighlights) {
[highlights addObject:@{@"id": h.highlightId, @"text": h.markedText ?: @""}];
}
dict[@"linkedHighlights"] = highlights;
}
if (_linkedUnderlines.count > 0) {
NSMutableArray *underlines = [NSMutableArray array];
for (WRPageUnderline *u in _linkedUnderlines) {
[underlines addObject:@{@"id": u.underlineId, @"text": u.markedText ?: @""}];
}
dict[@"linkedUnderlines"] = underlines;
}
return [dict copy];
}
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
{
if (!dict) return nil;
WRPageMark *mark = [[WRPageMark alloc]
initWithBookId:dict[@"bookId"]
chapterUid:dict[@"chapterUid"]
chapterOffset:[dict[@"chapterOffset"] integerValue]
pageIndex:[dict[@"pageIndex"] integerValue]
chapterTitle:dict[@"chapterTitle"] ?: @""];
mark.markId = dict[@"markId"];
mark.excerptText = dict[@"excerptText"];
mark.createTime = [dict[@"createTime"] doubleValue];
mark.updateTime = [dict[@"updateTime"] doubleValue];
mark.isSynced = [dict[@"isSynced"] boolValue];
mark.syncKey = dict[@"syncKey"];
mark.displayMode = [dict[@"displayMode"] integerValue];
return mark;
}
- (nullable NSData *)toJSONData
{
NSDictionary *dict = [self toDictionary];
return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
}
+ (nullable instancetype)fromJSONData:(NSData *)data
{
if (!data) return nil;
NSError *error = nil;
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
options:0
error:&error];
if (![dict isKindOfClass:[NSDictionary class]]) return nil;
return [self fromDictionary:dict];
}
#pragma mark - Batch Operations
+ (NSArray<WRPageMark *> *)marksSortedByPosition:(NSArray<WRPageMark *> *)marks
{
return [marks sortedArrayUsingSelector:@selector(compareByPosition:)];
}
+ (NSArray<WRPageMark *> *)marksSortedByDate:(NSArray<WRPageMark *> *)marks
{
return [marks sortedArrayUsingSelector:@selector(compareByDate:)];
}
+ (NSArray<WRPageMark *> *)marksInChapter:(NSString *)chapterUid
fromMarks:(NSArray<WRPageMark *> *)marks
{
return [marks filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:@"chapterUid == %@", chapterUid]];
}
+ (NSArray<WRPageMark *> *)marksInRange:(NSRange)range
fromMarks:(NSArray<WRPageMark *> *)marks
{
NSInteger start = (NSInteger)range.location;
NSInteger end = start + (NSInteger)range.length;
return [marks filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:
@"chapterOffset >= %ld AND chapterOffset < %ld", start, end]];
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:
@"<WRPageMark: %@ ch=%@ offset=%ld page=%ld highlights=%lu underlines=%lu>",
_markId, _chapterUid, (long)_chapterOffset, (long)_pageIndex,
(unsigned long)_linkedHighlights.count,
(unsigned long)_linkedUnderlines.count];
}
- (BOOL)isEqual:(id)object
{
if (self == object) return YES;
if (![object isKindOfClass:[WRPageMark class]]) return NO;
return [self.markId isEqualToString:((WRPageMark *)object).markId];
}
- (NSUInteger)hash
{
return self.markId.hash;
}
@end
+101
View File
@@ -0,0 +1,101 @@
//
// WRPageUnderline.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Annotation model for text underlines. 10 methods identified from binary.
// Supports multiple underline styles (solid, dashed, wavy, etc.)
// and is associated with a text range in a chapter.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBookmark;
// ---------------------------------------------------------------------------
// Underline styles
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
WRUnderlineStyleSolid = 0, // ____
WRUnderlineStyleDashed = 1, // ----
WRUnderlineStyleWavy = 2, // ~~~~
WRUnderlineStyleDotted = 3, // ....
};
// ---------------------------------------------------------------------------
// WRPageUnderline
// ---------------------------------------------------------------------------
@interface WRPageUnderline : NSObject
@property (nonatomic, copy) NSString *underlineId;
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, copy) NSString *chapterUid;
@property (nonatomic, assign) NSInteger startPos;
@property (nonatomic, assign) NSInteger endPos;
@property (nonatomic, assign) NSInteger chapterOffset;
@property (nonatomic, copy) NSString *markedText;
@property (nonatomic, assign) WRUnderlineStyle style;
@property (nonatomic, strong, nullable) UIColor *color;
@property (nonatomic, assign) NSInteger pageIndex;
@property (nonatomic, assign) NSTimeInterval createTime;
@property (nonatomic, assign) BOOL isSynced;
@property (nonatomic, copy, nullable) NSString *noteContent; // attached note
#pragma mark - Initialization
/// Initialize with text range and style.
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
style:(WRUnderlineStyle)style;
#pragma mark - Conversion
/// Convert to a WRBookmark for persistence/sync.
- (WRBookmark *)toBookmark;
/// Create from an existing bookmark.
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark;
#pragma mark - Drawing
/// Return the underline path for rendering in a given rect.
- (UIBezierPath *)underlinePathForRect:(CGRect)rect;
/// Return the underline color.
- (UIColor *)underlineColor;
#pragma mark - Methods (10 identified from binary, reconstructed)
/// Update the underline style.
- (void)setStyle:(WRUnderlineStyle)style;
/// Update the note content attached to this underline.
- (void)setNote:(NSString *)note;
/// Check if a given position falls within this underline's range.
- (BOOL)containsPosition:(NSInteger)position;
/// Merge with another underline (adjacent ranges).
- (BOOL)mergeWithUnderline:(WRPageUnderline *)other;
/// Return the text range length.
- (NSInteger)rangeLength;
/// Return a serialized dictionary.
- (NSDictionary *)toDictionary;
/// Create from a serialized dictionary.
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
/// Compare two underlines for ordering (by start position).
- (NSComparisonResult)compareTo:(WRPageUnderline *)other;
@end
NS_ASSUME_NONNULL_END
+261
View File
@@ -0,0 +1,261 @@
//
// WRPageUnderline.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 10 methods identified from binary. WRPageUnderline renders underline
// annotations with configurable styles (solid, dashed, wavy, dotted).
//
#import "WRPageUnderline.h"
#import "WRBookmark.h"
#pragma mark - Implementation
@implementation WRPageUnderline
#pragma mark - Initialization
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
style:(WRUnderlineStyle)style
{
self = [super init];
if (self) {
_underlineId = [[NSUUID UUID] UUIDString];
_bookId = [bookId copy];
_chapterUid = [chapterUid copy];
_startPos = startPos;
_endPos = endPos;
_chapterOffset = startPos;
_markedText = [text copy];
_style = style;
_color = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.8];
_createTime = [[NSDate date] timeIntervalSince1970];
_isSynced = NO;
}
return self;
}
#pragma mark - Conversion
- (WRBookmark *)toBookmark
{
WRBookmark *bm = [WRBookmark bookmarkWithBookId:_bookId
chapterUid:_chapterUid
offset:_startPos
text:_markedText
type:WRBookmarkTypeUnderline];
bm.bookmarkId = _underlineId;
bm.startPos = _startPos;
bm.endPos = _endPos;
bm.rangeLength = _endPos - _startPos;
bm.chapterOffset = _chapterOffset;
bm.noteContent = _noteContent;
bm.createTime = _createTime;
bm.isSynced = _isSynced;
// Store underline style in extra metadata
bm.extraMetadata = @{@"underlineStyle": @(_style)};
return bm;
}
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark
{
if (!bookmark || bookmark.type != WRBookmarkTypeUnderline) return nil;
WRPageUnderline *ul = [[WRPageUnderline alloc]
initWithBookId:bookmark.bookId
chapterUid:bookmark.chapterUid
startPos:bookmark.startPos
endPos:bookmark.endPos
text:bookmark.markText
style:[bookmark.extraMetadata[@"underlineStyle"] integerValue]];
ul.underlineId = bookmark.bookmarkId;
ul.chapterOffset = bookmark.chapterOffset;
ul.noteContent = bookmark.noteContent;
ul.createTime = bookmark.createTime;
ul.isSynced = bookmark.isSynced;
return ul;
}
#pragma mark - Drawing
- (UIBezierPath *)underlinePathForRect:(CGRect)rect
{
UIBezierPath *path = [UIBezierPath bezierPath];
CGFloat y = CGRectGetMaxY(rect) - 2.0; // 2pt below baseline
switch (_style) {
case WRUnderlineStyleSolid: {
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
path.lineWidth = 1.5;
break;
}
case WRUnderlineStyleDashed: {
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
path.lineWidth = 1.5;
// Set dash pattern: 6pt dash, 3pt gap
CGFloat pattern[] = {6.0, 3.0};
[path setLineDash:pattern count:2 phase:0];
break;
}
case WRUnderlineStyleWavy: {
// Wavy underline: sine wave approximation
CGFloat startX = CGRectGetMinX(rect);
CGFloat endX = CGRectGetMaxX(rect);
CGFloat width = endX - startX;
CGFloat amplitude = 2.0;
CGFloat wavelength = 8.0;
[path moveToPoint:CGPointMake(startX, y)];
for (CGFloat x = startX; x < endX; x += 1.0) {
CGFloat progress = (x - startX) / wavelength;
CGFloat waveY = y + sin(progress * M_PI * 2) * amplitude;
[path addLineToPoint:CGPointMake(x, waveY)];
}
path.lineWidth = 1.0;
break;
}
case WRUnderlineStyleDotted: {
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
path.lineWidth = 2.0;
CGFloat pattern[] = {1.0, 4.0};
[path setLineDash:pattern count:2 phase:0];
break;
}
}
return path;
}
- (UIColor *)underlineColor
{
return _color ?: [UIColor darkGrayColor];
}
#pragma mark - Public Methods
- (void)setStyle:(WRUnderlineStyle)style
{
_style = style;
}
- (void)setNote:(NSString *)note
{
_noteContent = [note copy];
}
- (BOOL)containsPosition:(NSInteger)position
{
return position >= _startPos && position < _endPos;
}
- (BOOL)mergeWithUnderline:(WRPageUnderline *)other
{
if (!other) return NO;
// Check if ranges are adjacent or overlapping
if (other.endPos < _startPos - 1 || other.startPos > _endPos + 1) {
return NO; // Not adjacent
}
// Check same book and chapter
if (![_bookId isEqualToString:other.bookId] ||
![_chapterUid isEqualToString:other.chapterUid]) {
return NO;
}
// Merge ranges
_startPos = MIN(_startPos, other.startPos);
_endPos = MAX(_endPos, other.endPos);
// Merge text (preserve order)
if (other.startPos < _startPos) {
_markedText = [other.markedText stringByAppendingString:_markedText];
} else {
_markedText = [_markedText stringByAppendingString:other.markedText];
}
return YES;
}
- (NSInteger)rangeLength
{
return _endPos - _startPos;
}
- (NSDictionary *)toDictionary
{
return @{
@"underlineId" : _underlineId ?: @"",
@"bookId" : _bookId ?: @"",
@"chapterUid" : _chapterUid ?: @"",
@"startPos" : @(_startPos),
@"endPos" : @(_endPos),
@"chapterOffset" : @(_chapterOffset),
@"markedText" : _markedText ?: @"",
@"style" : @(_style),
@"pageIndex" : @(_pageIndex),
@"createTime" : @(_createTime),
@"isSynced" : @(_isSynced),
@"noteContent" : _noteContent ?: @"",
};
}
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
{
if (!dict) return nil;
WRPageUnderline *ul = [[WRPageUnderline alloc]
initWithBookId:dict[@"bookId"]
chapterUid:dict[@"chapterUid"]
startPos:[dict[@"startPos"] integerValue]
endPos:[dict[@"endPos"] integerValue]
text:dict[@"markedText"]
style:[dict[@"style"] integerValue]];
ul.underlineId = dict[@"underlineId"];
ul.chapterOffset = [dict[@"chapterOffset"] integerValue];
ul.pageIndex = [dict[@"pageIndex"] integerValue];
ul.createTime = [dict[@"createTime"] doubleValue];
ul.isSynced = [dict[@"isSynced"] boolValue];
ul.noteContent = dict[@"noteContent"];
return ul;
}
- (NSComparisonResult)compareTo:(WRPageUnderline *)other
{
if (_startPos < other.startPos) return NSOrderedAscending;
if (_startPos > other.startPos) return NSOrderedDescending;
if (_endPos < other.endPos) return NSOrderedAscending;
if (_endPos > other.endPos) return NSOrderedDescending;
return NSOrderedSame;
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:
@"<WRPageUnderline: %@ style=%ld range=[%ld,%ld] '%@'>",
_underlineId, (long)_style, (long)_startPos, (long)_endPos,
[_markedText substringToIndex:MIN(30, _markedText.length)]];
}
@end
+168
View File
@@ -0,0 +1,168 @@
//
// WRPageView.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// The page rendering view. Inherits UIView. Uses CoreText direct drawing
// via drawRect: — does NOT use UILabel or UITextView for body text.
// Handles text selection via CoreText hit testing.
//
#import <UIKit/UIKit.h>
@class WRActivityIndicator;
@class WRLoadingProgressView;
@class WRFriendReviewsButton;
@class WRCoreTextLayoutFrame;
@class WRChapterData;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - WRPageViewDelegate
// ============================================================================
@protocol WRPageViewDelegate <NSObject>
@optional
/// Called when the user taps a link or internal reference.
- (void)pageView:(WRPageView *)pageView didTapLinkWithURL:(NSURL *)url;
/// Called when the user long-presses to initiate text selection.
- (void)pageView:(WRPageView *)pageView didBeginSelectionAtPoint:(CGPoint)point;
/// Called when text selection changes.
- (void)pageView:(WRPageView *)pageView selectionDidChangeWithRange:(NSRange)range;
/// Called when the user taps the friend reviews button.
- (void)pageView:(WRPageView *)pageView didTapFriendReviewsWithCount:(NSUInteger)count;
/// Called when the page needs to reload its content.
- (void)pageViewNeedsReload:(WRPageView *)pageView;
@end
// ============================================================================
#pragma mark - WRPageView
// ============================================================================
@interface WRPageView : UIView
// ---- Delegate ----
@property (nonatomic, weak, nullable) id<WRPageViewDelegate> delegate;
// ---- Content ----
/// The chapter data driving this page's content.
@property (nonatomic, strong, nullable) WRChapterData *chapterData;
/// The page index within the chapter (0-based).
@property (nonatomic, assign) NSUInteger pageIndex;
/// The layout frame used for CoreText rendering.
@property (nonatomic, strong, nullable) WRCoreTextLayoutFrame *layoutFrame;
// ---- Ivars reconstructed from binary (NSArray, NSMutableArray, etc.) ----
/// Array of attachment image descriptors (rects, image refs) for inline images.
@property (nonatomic, strong, nullable) NSArray *imageAttachments;
/// Mutable array tracking visible highlight/underline ranges.
@property (nonatomic, strong, nullable) NSMutableArray *visibleHighlights;
/// Timer for auto-read advance.
@property (nonatomic, strong, nullable) NSTimer *autoReadTimer;
/// Timer for loading timeout.
@property (nonatomic, strong, nullable) NSTimer *loadingTimeoutTimer;
/// Current chapter identifier string.
@property (nonatomic, copy, nullable) NSString *chapterId;
// ---- UI Elements (non-text, overlaid on the CoreText layer) ----
/// Header label showing chapter title or page number.
@property (nonatomic, strong, nullable) UILabel *headerLabel;
/// Background/decorative image view.
@property (nonatomic, strong, nullable) UIImageView *backgroundImageView;
/// Loading activity indicator.
@property (nonatomic, strong, nullable) WRActivityIndicator *activityIndicator;
/// Error / empty-state message label.
@property (nonatomic, strong, nullable) UILabel *statusLabel;
/// Retry button shown on error.
@property (nonatomic, strong, nullable) QMUIButton *retryButton;
/// Share button.
@property (nonatomic, strong, nullable) QMUIButton *shareButton;
/// Loading progress view (thin bar at top or bottom).
@property (nonatomic, strong, nullable) WRLoadingProgressView *loadingProgressView;
/// Bookmark toggle button.
@property (nonatomic, strong, nullable) QMUIButton *bookmarkButton;
/// Font size increase button (toolbar).
@property (nonatomic, strong, nullable) QMUIButton *fontSizeUpButton;
/// Font size decrease button (toolbar).
@property (nonatomic, strong, nullable) QMUIButton *fontSizeDownButton;
/// Friend reviews / notes button.
@property (nonatomic, strong, nullable) WRFriendReviewsButton *friendReviewsButton;
// ---- Selection state ----
/// Whether the view is currently in text-selection mode.
@property (nonatomic, assign, readonly) BOOL isSelecting;
/// The currently selected string range (NSNotFound if none).
@property (nonatomic, assign, readonly) NSRange selectedRange;
// ---- CoreText Drawing ----
/// Main drawing entry point. Draws text and inline images directly into the
/// CGContext using CoreText. Called by UIKit from -drawRect:.
- (void)drawInContext:(CGContextRef)context
withData:(WRChapterData *)chapterData
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position;
// ---- Accessibility ----
/// Overridden to provide a plain-text representation of the page content.
- (nullable NSString *)accessibilityValue;
/// Overridden to support VoiceOver page-scroll gestures.
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction;
// ---- Friend Reviews ----
/// Updates the friend reviews button with the current count.
- (void)friendReviewsCount:(NSUInteger)count;
// ---- Loading / Error States ----
/// Shows the loading indicator and optional progress bar.
- (void)showLoadingWithProgress:(float)progress;
/// Hides loading indicators.
- (void)hideLoading;
/// Shows an error state with a message and retry button.
- (void)showErrorWithMessage:(NSString *)message;
// ---- Text Selection (CoreText hit-testing) ----
/// Converts a point in the view to a string index using CTLineGetStringIndexForPosition.
- (NSInteger)stringIndexForPoint:(CGPoint)point;
/// Returns the character range for the line containing the given string index.
- (NSRange)lineRangeForStringIndex:(NSInteger)index;
/// Clears the current selection.
- (void)clearSelection;
@end
NS_ASSUME_NONNULL_END
+645
View File
@@ -0,0 +1,645 @@
//
// WRPageView.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the page rendering view.
// This view draws text directly via CoreText into CGContext — no UILabel
// or UITextView is used for the reading content.
//
#import "WRPageView.h"
#import "WRChapterData.h"
#import "WRCoreTextLayoutFrame.h"
#import "WRActivityIndicator.h"
#import "WRLoadingProgressView.h"
#import "WRFriendReviewsButton.h"
// ============================================================================
#pragma mark - Private Helpers
// ============================================================================
/// Extracts a plain-text NSString from a given range of the chapter's
/// attributed string, stripping any attachment characters.
static NSString *WRPlainStringFromRange(NSAttributedString *attrStr, NSRange range) {
if (!attrStr || range.location == NSNotFound) return @"";
NSString *raw = [[attrStr string] substringWithRange:range];
// Remove object replacement characters used for image attachments.
return [raw stringByReplacingOccurrencesOfString:@"" withString:@""];
}
/// Converts a UITouch point from view coordinates to the flipped coordinate
/// system expected by CoreText (origin at bottom-left).
static CGPoint WRSFlipPointForCoreText(CGPoint viewPoint, CGFloat viewHeight) {
return CGPointMake(viewPoint.x, viewHeight - viewPoint.y);
}
// ============================================================================
#pragma mark - WRPageView ()
// ============================================================================
@interface WRPageView ()
// ---- Private selection tracking ----
@property (nonatomic, assign) NSInteger selectionStartIndex;
@property (nonatomic, assign) NSInteger selectionEndIndex;
@property (nonatomic, assign) BOOL isSelecting;
// ---- Gesture recognizers ----
@property (nonatomic, strong) UITapGestureRecognizer *singleTapGR;
@property (nonatomic, strong) UILongPressGestureRecognizer *longPressGR;
@property (nonatomic, strong) UIPanGestureRecognizer *panGR;
// ---- Highlight/underline drawing cache ----
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *highlightRects;
@end
// ============================================================================
#pragma mark - WRPageView Implementation
// ============================================================================
@implementation WRPageView
// ---- Synthesize properties backed by ivars from the binary ----
// The binary shows these ivar types:
// NSArray -> imageAttachments
// NSMutableArray -> visibleHighlights
// NSTimer -> autoReadTimer
// NSTimer -> loadingTimeoutTimer
// NSString -> chapterId
// UILabel -> headerLabel
// UIImageView -> backgroundImageView
// WRActivityIndicator -> activityIndicator
// UILabel -> statusLabel
// QMUIButton -> retryButton
// QMUIButton -> shareButton
// WRLoadingProgressView -> loadingProgressView
// QMUIButton -> bookmarkButton
// QMUIButton -> fontSizeUpButton
// QMUIButton -> fontSizeDownButton
// WRFriendReviewsButton -> friendReviewsButton
// ============================================================================
#pragma mark - Lifecycle
// ============================================================================
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
// CoreText views must be opaque and have a cleared background for
// the CGContext to render correctly.
self.opaque = YES;
self.backgroundColor = [UIColor whiteColor];
self.clearsContextBeforeDrawing = YES;
// Enable multiple touches for selection handles.
self.multipleTouchEnabled = YES;
_selectionStartIndex = NSNotFound;
_selectionEndIndex = NSNotFound;
_isSelecting = NO;
[self _setupSubviews];
[self _setupGestureRecognizers];
}
return self;
}
- (void)dealloc {
[_autoReadTimer invalidate];
[_loadingTimeoutTimer invalidate];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - Subview Setup
// ============================================================================
/// Creates the non-text overlay UI elements. The reading text itself is
/// rendered entirely in -drawRect: via CoreText, so these are all floating
/// controls layered on top.
- (void)_setupSubviews {
// ---- Background image (e.g., paper texture) ----
_backgroundImageView = [[UIImageView alloc] initWithFrame:self.bounds];
_backgroundImageView.autoresizingMask = UIViewAutoresizingFlexibleWidth |
UIViewAutoresizingFlexibleHeight;
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
[self addSubview:_backgroundImageView];
// ---- Header label (chapter title, page indicator) ----
_headerLabel = [[UILabel alloc] initWithFrame:CGRectZero];
_headerLabel.font = [UIFont systemFontOfSize:12.0];
_headerLabel.textColor = [UIColor grayColor];
_headerLabel.textAlignment = NSTextAlignmentCenter;
[self addSubview:_headerLabel];
// ---- Activity indicator (shown during chapter load) ----
_activityIndicator = [[WRActivityIndicator alloc] initWithFrame:CGRectZero];
_activityIndicator.hidden = YES;
[self addSubview:_activityIndicator];
// ---- Status label (error / empty state) ----
_statusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
_statusLabel.font = [UIFont systemFontOfSize:15.0];
_statusLabel.textColor = [UIColor darkGrayColor];
_statusLabel.textAlignment = NSTextAlignmentCenter;
_statusLabel.numberOfLines = 0;
_statusLabel.hidden = YES;
[self addSubview:_statusLabel];
// ---- Retry button ----
_retryButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_retryButton setTitle:@"重试" forState:UIControlStateNormal]; // "Retry"
[_retryButton addTarget:self
action:@selector(_retryButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
_retryButton.hidden = YES;
[self addSubview:_retryButton];
// ---- Share button ----
_shareButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_shareButton setImage:[UIImage imageNamed:@"icon_share"]
forState:UIControlStateNormal];
[_shareButton addTarget:self
action:@selector(_shareButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
_shareButton.hidden = YES;
[self addSubview:_shareButton];
// ---- Loading progress view ----
_loadingProgressView = [[WRLoadingProgressView alloc] initWithFrame:CGRectZero];
_loadingProgressView.hidden = YES;
[self addSubview:_loadingProgressView];
// ---- Bookmark button ----
_bookmarkButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_bookmarkButton addTarget:self
action:@selector(_bookmarkButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_bookmarkButton];
// ---- Font size buttons ----
_fontSizeUpButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_fontSizeUpButton setTitle:@"A+" forState:UIControlStateNormal];
[_fontSizeUpButton addTarget:self
action:@selector(_fontSizeUpTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_fontSizeUpButton];
_fontSizeDownButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_fontSizeDownButton setTitle:@"A-" forState:UIControlStateNormal];
[_fontSizeDownButton addTarget:self
action:@selector(_fontSizeDownTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_fontSizeDownButton];
// ---- Friend reviews button ----
_friendReviewsButton = [[WRFriendReviewsButton alloc] initWithFrame:CGRectZero];
[_friendReviewsButton addTarget:self
action:@selector(_friendReviewsTapped:)
forControlEvents:UIControlEventTouchUpInside];
_friendReviewsButton.hidden = YES;
[self addSubview:_friendReviewsButton];
}
// ============================================================================
#pragma mark - Gesture Recognizers
// ============================================================================
- (void)_setupGestureRecognizers {
// Single tap: link detection, selection dismissal, toolbar toggle.
_singleTapGR = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(_handleSingleTap:)];
_singleTapGR.numberOfTapsRequired = 1;
[self addGestureRecognizer:_singleTapGR];
// Long press: initiate text selection.
_longPressGR = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(_handleLongPress:)];
_longPressGR.minimumPressDuration = 0.5;
[self addGestureRecognizer:_longPressGR];
// Pan: extend selection after long press.
_panGR = [[UIPanGestureRecognizer alloc]
initWithTarget:self action:@selector(_handlePan:)];
_panGR.enabled = NO; // Enabled only when selecting.
[self addGestureRecognizer:_panGR];
// Long press should fail before single tap fires.
[_singleTapGR requireGestureRecognizerToFail:_longPressGR];
}
// ============================================================================
#pragma mark - Layout
// ============================================================================
- (void)layoutSubviews {
[super layoutSubviews];
// Position the header label at the top edge with padding.
CGFloat headerHeight = 20.0;
_headerLabel.frame = CGRectMake(16.0, 8.0,
CGRectGetWidth(self.bounds) - 32.0,
headerHeight);
// Center the activity indicator.
_activityIndicator.center = CGPointMake(CGRectGetMidX(self.bounds),
CGRectGetMidY(self.bounds));
// Position the friend reviews button at bottom-right.
CGSize reviewSize = CGSizeMake(60.0, 30.0);
_friendReviewsButton.frame =
CGRectMake(CGRectGetWidth(self.bounds) - reviewSize.width - 16.0,
CGRectGetHeight(self.bounds) - reviewSize.height - 40.0,
reviewSize.width, reviewSize.height);
}
// ============================================================================
#pragma mark - CoreText Drawing (drawRect:)
// ============================================================================
///
/// This is the heart of the page view. It draws the chapter text directly
/// into the CGContext using CoreText. No UILabel or UITextView is involved.
///
/// The flow is:
/// 1. Get the current graphics context.
/// 2. Flip the coordinate system for CoreText (origin at bottom-left).
/// 3. Call WRCoreTextLayoutFrame to draw the attributed string runs.
/// 4. Draw inline images at their attachment positions.
/// 5. Draw highlight/underline overlays.
///
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
if (!ctx) return;
// If there is no chapter data or layout frame, just clear and return.
if (!self.chapterData || !self.layoutFrame) {
CGContextClearRect(ctx, rect);
return;
}
// ---- Step 1: Fill background ----
UIColor *bgColor = self.backgroundColor ?: [UIColor whiteColor];
CGContextSetFillColorWithColor(ctx, bgColor.CGColor);
CGContextFillRect(ctx, rect);
// ---- Step 2: Flip coordinate system for CoreText ----
// CoreText uses a bottom-left origin; UIKit uses top-left.
CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
CGContextTranslateCTM(ctx, 0.0, CGRectGetHeight(self.bounds));
CGContextScaleCTM(ctx, 1.0, -1.0);
// ---- Step 3: Draw the layout frame ----
// This calls WRCoreTextLayoutFrame -drawInContext:image:size:inRect:position:
// which iterates over CTLine objects, draws glyphs, and positions images.
CGSize pageSize = self.bounds.size;
CGPoint drawOrigin = CGPointMake(0.0, 0.0); // Could include margins.
CGRect drawRect = UIEdgeInsetsInsetRect(self.bounds,
self.chapterData.contentInsets);
[self.layoutFrame drawInContext:ctx
image:self.backgroundImageView.image
size:pageSize
inRect:drawRect
position:drawOrigin];
// ---- Step 4: Draw highlights and underlines ----
[self _drawHighlightsInContext:ctx];
// ---- Step 5: Draw selection handles if selecting ----
if (self.isSelecting) {
[self _drawSelectionInContext:ctx];
}
}
/// Draws highlight rectangles and underline paths for annotations,
/// bookmarks, and the current selection.
- (void)_drawHighlightsInContext:(CGContextRef)ctx {
for (NSDictionary *entry in self.highlightRects) {
CGRect hlRect = [entry[@"rect"] CGRectValue];
UIColor *color = entry[@"color"] ?: [UIColor yellowColor];
BOOL isUnderline = [entry[@"underline"] boolValue];
CGContextSetFillColorWithColor(ctx,
[color colorWithAlphaComponent:0.3].CGColor);
CGContextFillRect(ctx, hlRect);
if (isUnderline) {
CGFloat y = CGRectGetMaxY(hlRect);
CGContextSetStrokeColorWithColor(ctx, color.CGColor);
CGContextSetLineWidth(ctx, 1.0);
CGContextMoveToPoint(ctx, CGRectGetMinX(hlRect), y);
CGContextAddLineToPoint(ctx, CGRectGetMaxX(hlRect), y);
CGContextStrokePath(ctx);
}
}
}
/// Draws the selection highlight between selectionStartIndex and
/// selectionEndIndex using CTLineGetOffsetForStringIndex.
- (void)_drawSelectionInContext:(CGContextRef)ctx {
if (_selectionStartIndex == NSNotFound || _selectionEndIndex == NSNotFound) {
return;
}
// Clamp range.
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
// Ask the layout frame for the rects covering this range.
NSArray<NSValue *> *rects = [self.layoutFrame rectsWithinRange:selRange];
UIColor *selColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:0.3];
for (NSValue *val in rects) {
CGRect r = [val CGRectValue];
CGContextSetFillColorWithColor(ctx, selColor.CGColor);
CGContextFillRect(ctx, r);
}
}
// ============================================================================
#pragma mark - Drawing API (called externally)
// ============================================================================
/// External entry point for drawing. Delegates to the layout frame.
- (void)drawInContext:(CGContextRef)context
withData:(WRChapterData *)chapterData
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position {
// Store references so -drawRect: can use them.
self.chapterData = chapterData;
// Trigger a redraw.
[self setNeedsDisplay];
}
// ============================================================================
#pragma mark - Text Selection via CoreText Hit Testing
// ============================================================================
/// Converts a view-coordinate point to a string index in the attributed string
/// using CTLineGetStringIndexForPosition.
- (NSInteger)stringIndexForPoint:(CGPoint)point {
if (!self.layoutFrame) return NSNotFound;
// Flip Y for CoreText.
CGPoint ctPoint = WRSFlipPointForCoreText(point, CGRectGetHeight(self.bounds));
// Walk the CTLine objects in the layout frame to find the line at this Y,
// then use CTLineGetStringIndexForPosition to get the character index.
NSInteger index = [self.layoutFrame stringIndexForPoint:ctPoint];
return index;
}
/// Returns the range of the line containing the given string index,
/// using CTLineGetOffsetForStringIndex to find line boundaries.
- (NSRange)lineRangeForStringIndex:(NSInteger)index {
if (!self.layoutFrame || index == NSNotFound) {
return NSMakeRange(NSNotFound, 0);
}
return [self.layoutFrame lineRangeForStringIndex:index];
}
/// Clears the current text selection and disables the pan gesture.
- (void)clearSelection {
_selectionStartIndex = NSNotFound;
_selectionEndIndex = NSNotFound;
_isSelecting = NO;
_panGR.enabled = NO;
[self setNeedsDisplay];
}
// ============================================================================
#pragma mark - Gesture Handlers
// ============================================================================
- (void)_handleSingleTap:(UITapGestureRecognizer *)gr {
CGPoint pt = [gr locationInView:self];
// If currently selecting, dismiss selection.
if (self.isSelecting) {
[self clearSelection];
return;
}
// Check if the tap hits a link in the layout frame.
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound) {
NSURL *linkURL = [self.layoutFrame linkURLAtIndex:idx];
if (linkURL) {
if ([self.delegate respondsToSelector:@selector(pageView:didTapLinkWithURL:)]) {
[self.delegate pageView:self didTapLinkWithURL:linkURL];
}
return;
}
}
// Otherwise, toggle toolbar / delegate the tap.
// (In the real app, this toggles the reader chrome.)
}
- (void)_handleLongPress:(UILongPressGestureRecognizer *)gr {
if (gr.state == UIGestureRecognizerStateBegan) {
CGPoint pt = [gr locationInView:self];
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound) {
_selectionStartIndex = idx;
_selectionEndIndex = idx;
_isSelecting = YES;
_panGR.enabled = YES;
[self setNeedsDisplay];
if ([self.delegate respondsToSelector:@selector(pageView:didBeginSelectionAtPoint:)]) {
[self.delegate pageView:self didBeginSelectionAtPoint:pt];
}
}
}
}
- (void)_handlePan:(UIPanGestureRecognizer *)gr {
if (!self.isSelecting) return;
CGPoint pt = [gr locationInView:self];
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound && idx != _selectionEndIndex) {
_selectionEndIndex = idx;
[self setNeedsDisplay];
// Notify delegate of selection range change.
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
if ([self.delegate respondsToSelector:@selector(pageView:selectionDidChangeWithRange:)]) {
[self.delegate pageView:self selectionDidChangeWithRange:selRange];
}
}
}
// ============================================================================
#pragma mark - Accessibility
// ============================================================================
/// Provides a plain-text representation of the page for VoiceOver.
- (nullable NSString *)accessibilityValue {
if (!self.chapterData) return nil;
NSAttributedString *attrStr = self.chapterData.typesetAttributedString;
if (!attrStr) return nil;
// Return the plain text for the current page range.
NSRange pageRange = [self.chapterData rangeOfPage:self.pageIndex];
return WRPlainStringFromRange(attrStr, pageRange);
}
/// Supports VoiceOver scroll gestures to flip pages.
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
// Post a page-change notification for the WRPageViewController to handle.
NSString *dirStr = nil;
switch (direction) {
case UIAccessibilityScrollDirectionLeft:
dirStr = @"next";
break;
case UIAccessibilityScrollDirectionRight:
dirStr = @"previous";
break;
default:
return NO;
}
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewAccessibilityScroll"
object:self
userInfo:@{@"direction": dirStr}];
return YES;
}
// ============================================================================
#pragma mark - Friend Reviews
// ============================================================================
- (void)friendReviewsCount:(NSUInteger)count {
self.friendReviewsButton.hidden = (count == 0);
[self.friendReviewsButton setReviewCount:count];
}
// ============================================================================
#pragma mark - Loading / Error States
// ============================================================================
- (void)showLoadingWithProgress:(float)progress {
self.activityIndicator.hidden = NO;
[self.activityIndicator startAnimating];
if (progress > 0.0 && progress < 1.0) {
self.loadingProgressView.hidden = NO;
self.loadingProgressView.progress = progress;
} else {
self.loadingProgressView.hidden = YES;
}
// Start a timeout timer — if loading takes too long, show an error.
[_loadingTimeoutTimer invalidate];
_loadingTimeoutTimer =
[NSTimer scheduledTimerWithTimeInterval:15.0
target:self
selector:@selector(_loadingDidTimeout)
userInfo:nil
repeats:NO];
}
- (void)hideLoading {
[_loadingTimeoutTimer invalidate];
_loadingTimeoutTimer = nil;
[self.activityIndicator stopAnimating];
self.activityIndicator.hidden = YES;
self.loadingProgressView.hidden = YES;
}
- (void)showErrorWithMessage:(NSString *)message {
[self hideLoading];
self.statusLabel.text = message;
self.statusLabel.hidden = NO;
self.retryButton.hidden = NO;
}
- (void)_loadingDidTimeout {
[self showErrorWithMessage:@"加载超时,请重试"]; // "Loading timed out, please retry"
}
// ============================================================================
#pragma mark - Button Actions
// ============================================================================
- (void)_retryButtonTapped:(QMUIButton *)sender {
self.statusLabel.hidden = YES;
self.retryButton.hidden = YES;
if ([self.delegate respondsToSelector:@selector(pageViewNeedsReload:)]) {
[self.delegate pageViewNeedsReload:self];
}
}
- (void)_shareButtonTapped:(QMUIButton *)sender {
// Share current page content.
// Handled by delegate or responder chain.
}
- (void)_bookmarkButtonTapped:(QMUIButton *)sender {
// Toggle bookmark for current page.
sender.selected = !sender.selected;
}
- (void)_fontSizeUpTapped:(QMUIButton *)sender {
// Increase font size — triggers re-typeset.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewFontSizeChange"
object:self
userInfo:@{@"delta": @(1)}];
}
- (void)_fontSizeDownTapped:(QMUIButton *)sender {
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewFontSizeChange"
object:self
userInfo:@{@"delta": @(-1)}];
}
- (void)_friendReviewsTapped:(QMUIButton *)sender {
if ([self.delegate respondsToSelector:@selector(pageView:didTapFriendReviewsWithCount:)]) {
[self.delegate pageView:self
didTapFriendReviewsWithCount:sender.tag];
}
}
// ============================================================================
#pragma mark - Auto-Read Timer
// ============================================================================
/// Starts the auto-read timer that periodically advances the page.
- (void)startAutoReadWithInterval:(NSTimeInterval)interval {
[_autoReadTimer invalidate];
_autoReadTimer =
[NSTimer scheduledTimerWithTimeInterval:interval
target:self
selector:@selector(_autoReadTick)
userInfo:nil
repeats:YES];
}
- (void)stopAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer = nil;
}
- (void)_autoReadTick {
// Post notification for the page view controller to advance.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewAutoReadAdvance"
object:self];
}
@end
@@ -0,0 +1,175 @@
//
// WRPageViewController.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Based on UIPageViewController. Supports UIPageCurl (simulation) and
// Scroll (slide) page turning styles. Includes fault detection and
// patching mechanisms for known UIPageViewController bugs.
//
#import <UIKit/UIKit.h>
@class WRPageView;
@class WRChapterData;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - Page Flipping Style
// ============================================================================
/// The visual style used when transitioning between pages.
typedef NS_ENUM(NSInteger, WRPageFlippingStyle) {
WRPageFlippingStyleCurl = 0, // UIPageCurl simulation (paper fold)
WRPageFlippingStyleSlide = 1, // UIScrollView-based horizontal slide
WRPageFlippingStyleFade = 2, // Crossfade transition
WRPageFlippingStyleNone = 3, // Instant switch, no animation
};
// ============================================================================
#pragma mark - WRPageViewControllerDelegate
// ============================================================================
@protocol WRPageViewControllerDelegate <NSObject>
@optional
/// Called when the page view controller transitions to a new page.
- (void)pageViewController:(WRPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed;
/// Called before a page transition begins.
- (void)pageViewController:(WRPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers;
/// Called to determine the spine location for interface orientation changes.
- (UIPageViewControllerSpineLocation)pageViewController:(WRPageViewController *)pageViewController
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation;
/// Called when the current page index changes.
- (void)pageViewController:(WRPageViewController *)pageViewController
didChangePageIndex:(NSUInteger)pageIndex;
@end
// ============================================================================
#pragma mark - WRPageViewControllerDataSource
// ============================================================================
@protocol WRPageViewControllerDataSource <NSObject>
/// Returns the view controller before the given one (for right-to-left paging).
- (nullable UIViewController *)pageViewController:(WRPageViewController *)pageViewController
viewControllerBeforeViewController:(UIViewController *)viewController;
/// Returns the view controller after the given one (for left-to-right paging).
- (nullable UIViewController *)pageViewController:(WRPageViewController *)pageViewController
viewControllerAfterViewController:(UIViewController *)viewController;
@end
// ============================================================================
#pragma mark - WRPageViewController
// ============================================================================
@interface WRPageViewController : UIViewController
// ---- Delegates ----
@property (nonatomic, weak, nullable) id<WRPageViewControllerDelegate> pageDelegate;
@property (nonatomic, weak, nullable) id<WRPageViewControllerDataSource> pageDataSource;
// ---- Configuration ----
/// The current page flipping style.
@property (nonatomic, assign) WRPageFlippingStyle pageFlippingStyle;
/// Whether the user can interact to change pages.
@property (nonatomic, assign) BOOL pagingEnabled;
/// The underlying UIPageViewController used for page curl and scroll styles.
@property (nonatomic, strong, readonly) UIPageViewController *uiPageViewController;
// ---- State ----
/// The currently visible page index (0-based within the current chapter).
@property (nonatomic, assign, readonly) NSUInteger currentPageIndex;
/// Whether a page transition is currently in progress.
@property (nonatomic, assign, readonly) BOOL isTransitioning;
// ============================================================================
#pragma mark - Initialization
// ============================================================================
/// Designated initializer.
/// @param delegate The page delegate.
/// @param pageType The UIPageViewControllerTransitionStyle (curl or scroll).
/// @param flippingStyle The WRPageFlippingStyle to use.
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
withPageType:(UIPageViewControllerTransitionStyle)pageType
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle;
// ============================================================================
#pragma mark - Page Navigation
// ============================================================================
/// Sets the current page index, optionally animated.
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated;
/// Advances to the next page. Returns YES if successful, NO if at the end.
- (BOOL)goToNextPageAnimated:(BOOL)animated;
/// Goes back to the previous page. Returns YES if successful, NO if at the beginning.
- (BOOL)goToPreviousPageAnimated:(BOOL)animated;
/// Replaces the currently displayed view controllers.
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
direction:(UIPageViewControllerNavigationDirection)direction
animated:(BOOL)animated
completion:(void (^ __nullable)(BOOL finished))completion;
// ============================================================================
#pragma mark - Fault Detection & Patching
// ============================================================================
///
/// Detects a navigation direction crash in UIPageViewController's
/// queuingScrollView:didScrollWithAnimation: callback.
///
/// This addresses a known Apple bug where the internal
/// UIPageViewControllerQueuingScrollView can enter an inconsistent state
/// and crash with an invalid navigation direction.
///
/// @param pageVC The UIPageViewController to check.
/// @param queuingScrollView The internal queuing scroll view (if accessible).
/// @param methodDidScroll Whether this is called from the didScroll callback.
/// @param force Force the detection even if already patched.
/// @param logDetail Whether to log detailed diagnostic info.
/// @param logCrashReason Whether to log the crash reason string.
/// @return YES if a fault was detected (and hopefully patched).
///
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
queuingScrollView:(UIScrollView *)queuingScrollView
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
force:(BOOL)force
logDetail:(BOOL)logDetail
logCrashReason:(BOOL)logCrashReason;
///
/// Patches the navigation direction fault by swizzling or resetting
/// the internal state of UIPageViewController.
+ (void)patchNavigationDirectionFault;
///
/// Patches the "no view controller managing page view" fault.
/// This occurs when UIPageViewController loses track of its child VCs.
+ (void)patchNoViewControllerManagingPageViewFault;
///
/// Patches the UIPageCurl animation fault that can cause visual glitches
/// or crashes during rapid page flipping.
+ (void)patchUIPageCurlFault;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,602 @@
//
// WRPageViewController.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the page view controller.
// Wraps UIPageViewController with fault detection and patching for
// known Apple bugs in the page curl and scroll transition styles.
//
#import "WRPageViewController.h"
#import <objc/runtime.h>
// ============================================================================
#pragma mark - Constants
// ============================================================================
/// Notification posted when a page transition completes.
static NSString *const kWRPageDidTransitionNotification =
@"WRPageViewControllerDidTransition";
/// UserDefaults key to track whether the nav-direction fault has been patched.
static NSString *const kNavDirectionPatchedKey =
@"WRPageVC_NavDirectionPatched";
// ============================================================================
#pragma mark - Private Forward Declarations
// ============================================================================
@interface WRPageViewController () <UIPageViewControllerDelegate,
UIPageViewControllerDataSource,
UIGestureRecognizerDelegate>
@property (nonatomic, strong) UIPageViewController *uiPageViewController;
@property (nonatomic, assign) NSUInteger currentPageIndex;
@property (nonatomic, assign) BOOL isTransitioning;
/// Pending completion block for -setViewControllers:direction:animated:completion:.
@property (nonatomic, copy, nullable) void (^pendingTransitionCompletion)(BOOL);
@end
// ============================================================================
#pragma mark - Fault Patching State
// ============================================================================
/// Static flag: whether the navigation direction fault has been detected
/// in this process lifetime.
static BOOL s_NavDirectionFaultDetected = NO;
/// Static flag: whether the page curl fault has been patched.
static BOOL s_PageCurlFaultPatched = NO;
// ============================================================================
#pragma mark - WRPageViewController Implementation
// ============================================================================
@implementation WRPageViewController
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
withPageType:(UIPageViewControllerTransitionStyle)pageType
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_pageDelegate = delegate;
_pageFlippingStyle = flippingStyle;
_pagingEnabled = YES;
_currentPageIndex = 0;
_isTransitioning = NO;
// Determine the UIPageViewController transition style.
UIPageViewControllerTransitionStyle uiStyle;
switch (flippingStyle) {
case WRPageFlippingStyleCurl:
uiStyle = UIPageViewControllerTransitionStylePageCurl;
break;
case WRPageFlippingStyleSlide:
case WRPageFlippingStyleFade:
case WRPageFlippingStyleNone:
default:
uiStyle = UIPageViewControllerTransitionStyleScroll;
break;
}
// Create the underlying UIPageViewController.
NSDictionary *options = @{
UIPageViewControllerOptionInterPageSpacingKey: @(20.0),
};
_uiPageViewController =
[[UIPageViewController alloc] initWithTransitionStyle:uiStyle
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
options:options];
_uiPageViewController.delegate = self;
_uiPageViewController.dataSource = self;
// Apply known fault patches proactively.
[WRPageViewController patchNavigationDirectionFault];
if (flippingStyle == WRPageFlippingStyleCurl) {
[WRPageViewController patchUIPageCurlFault];
}
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
// Embed the UIPageViewController as a child.
[self addChildViewController:self.uiPageViewController];
self.uiPageViewController.view.frame = self.view.bounds;
self.uiPageViewController.view.autoresizingMask =
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.uiPageViewController.view];
[self.uiPageViewController didMoveToParentViewController:self];
// Configure gesture recognizers.
[self _configureGestureRecognizers];
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
self.uiPageViewController.view.frame = self.view.bounds;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - Gesture Recognizer Configuration
// ============================================================================
/// Configures the page view controller's gesture recognizers.
/// In scroll mode, the internal UIScrollView handles paging.
/// In curl mode, the tap-to-flip gesture is added.
- (void)_configureGestureRecognizers {
if (self.pageFlippingStyle == WRPageFlippingStyleCurl) {
// Add tap zones for curl: left 1/4 and right 1/4 of the screen.
UITapGestureRecognizer *leftTap =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(_handleLeftTap:)];
leftTap.delegate = self;
[self.view addGestureRecognizer:leftTap];
UITapGestureRecognizer *rightTap =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(_handleRightTap:)];
rightTap.delegate = self;
[self.view addGestureRecognizer:rightTap];
}
}
- (void)_handleLeftTap:(UITapGestureRecognizer *)gr {
[self goToPreviousPageAnimated:YES];
}
- (void)_handleRightTap:(UITapGestureRecognizer *)gr {
[self goToNextPageAnimated:YES];
}
// ============================================================================
#pragma mark - Page Navigation
// ============================================================================
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated {
if (pageIndex == _currentPageIndex) return;
UIPageViewControllerNavigationDirection direction =
(pageIndex > _currentPageIndex)
? UIPageViewControllerNavigationDirectionForward
: UIPageViewControllerNavigationDirectionReverse;
_currentPageIndex = pageIndex;
// Notify delegate.
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:didChangePageIndex:)]) {
[self.pageDelegate pageViewController:self didChangePageIndex:pageIndex];
}
}
- (BOOL)goToNextPageAnimated:(BOOL)animated {
if (!self.pagingEnabled || self.isTransitioning) return NO;
// Ask the data source for the next view controller.
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
if (!currentVC) return NO;
UIViewController *nextVC =
[self.pageDataSource pageViewController:self
viewControllerAfterViewController:currentVC];
if (!nextVC) return NO; // At the end.
self.isTransitioning = YES;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:@[nextVC]
direction:UIPageViewControllerNavigationDirectionForward
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (finished) {
strongSelf.currentPageIndex++;
}
}];
return YES;
}
- (BOOL)goToPreviousPageAnimated:(BOOL)animated {
if (!self.pagingEnabled || self.isTransitioning) return NO;
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
if (!currentVC) return NO;
UIViewController *prevVC =
[self.pageDataSource pageViewController:self
viewControllerBeforeViewController:currentVC];
if (!prevVC) return NO; // At the beginning.
self.isTransitioning = YES;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:@[prevVC]
direction:UIPageViewControllerNavigationDirectionReverse
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (finished && strongSelf.currentPageIndex > 0) {
strongSelf.currentPageIndex--;
}
}];
return YES;
}
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
direction:(UIPageViewControllerNavigationDirection)direction
animated:(BOOL)animated
completion:(void (^ __nullable)(BOOL finished))completion {
self.pendingTransitionCompletion = completion;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:viewControllers
direction:direction
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (strongSelf.pendingTransitionCompletion) {
strongSelf.pendingTransitionCompletion(finished);
strongSelf.pendingTransitionCompletion = nil;
}
}];
}
// ============================================================================
#pragma mark - UIPageViewControllerDataSource
// ============================================================================
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
viewControllerBeforeViewController:(UIViewController *)viewController {
// Delegate to our data source.
if ([self.pageDataSource respondsToSelector:
@selector(pageViewController:viewControllerBeforeViewController:)]) {
return [self.pageDataSource pageViewController:self
viewControllerBeforeViewController:viewController];
}
return nil;
}
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
viewControllerAfterViewController:(UIViewController *)viewController {
if ([self.pageDataSource respondsToSelector:
@selector(pageViewController:viewControllerAfterViewController:)]) {
return [self.pageDataSource pageViewController:self
viewControllerAfterViewController:viewController];
}
return nil;
}
// ============================================================================
#pragma mark - UIPageViewControllerDelegate
// ============================================================================
- (void)pageViewController:(UIPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
self.isTransitioning = YES;
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:willTransitionToViewControllers:)]) {
[self.pageDelegate pageViewController:self
willTransitionToViewControllers:pendingViewControllers];
}
}
- (void)pageViewController:(UIPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed {
self.isTransitioning = NO;
if (completed) {
// Detect the navigation direction fault.
// This is called after every completed transition to check if
// UIPageViewController's internal state is still consistent.
[WRPageViewController detectNavigationDirectionCrashWithPageViewController:pageViewController
queuingScrollView:nil
inMethodDidScrollWithAnimation:NO
force:NO
logDetail:YES
logCrashReason:YES];
}
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:)]) {
[self.pageDelegate pageViewController:self
didFinishAnimating:finished
previousViewControllers:previousViewControllers
transitionCompleted:completed];
}
}
- (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation {
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:spineLocationForInterfaceOrientation:)]) {
return [self.pageDelegate pageViewController:self
spineLocationForInterfaceOrientation:orientation];
}
return UIPageViewControllerSpineLocationMin;
}
// ============================================================================
#pragma mark - Fault Detection & Patching
// ============================================================================
///
/// Detects the navigation direction crash in UIPageViewController.
///
/// BACKGROUND:
/// UIPageViewController uses an internal "QueuingScrollView" (a private
/// UIScrollView subclass) to manage page transitions. During rapid swiping
/// or when the app enters the background during a transition, the internal
/// state machine can enter an inconsistent state where:
/// - The navigation direction is set to an invalid value.
/// - The queuing scroll view's contentOffset is inconsistent with the
/// current view controller set.
/// - A subsequent -scrollViewDidScroll: callback accesses deallocated
/// or nil view controllers.
///
/// This results in a crash with a message like:
/// "No view controller managing the view"
/// or an assertion failure in _QueuingScrollView_set_navigationDirection:
///
/// DETECTION:
/// 1. Check if the UIPageViewController's internal _queuingScrollView exists.
/// 2. Verify its contentOffset is within expected bounds.
/// 3. Verify the number of view controllers in the queue matches expectations.
/// 4. If any check fails, set s_NavDirectionFaultDetected = YES.
///
/// PATCHING:
/// If a fault is detected, we:
/// 1. Reset the queuing scroll view's contentOffset to a known good state.
/// 2. Remove and re-add the current view controllers to force a state reset.
/// 3. Log the fault for crash analytics.
///
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
queuingScrollView:(UIScrollView *)queuingScrollView
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
force:(BOOL)force
logDetail:(BOOL)logDetail
logCrashReason:(BOOL)logCrashReason {
// Skip if already detected and not forced.
if (s_NavDirectionFaultDetected && !force) return NO;
// ---- Access the private _queuingScrollView ----
UIScrollView *scrollView = queuingScrollView;
if (!scrollView) {
// Try to access via KVC (private API).
@try {
scrollView = [pageVC valueForKey:@"_queuingScrollView"];
} @catch (NSException *e) {
if (logCrashReason) {
NSLog(@"[WRPageVC] Could not access _queuingScrollView: %@", e);
}
return NO;
}
}
if (!scrollView) return NO;
// ---- Check 1: Content offset bounds ----
CGFloat contentWidth = scrollView.contentSize.width;
CGFloat offsetX = scrollView.contentOffset.x;
CGFloat frameWidth = scrollView.bounds.size.width;
// The content offset should be a multiple of the frame width
// (one page at a time). If it's in between, the state is inconsistent.
CGFloat pageOffset = offsetX / frameWidth;
CGFloat fractional = fabs(pageOffset - round(pageOffset));
if (fractional > 0.01 && methodDidScroll) {
// We're mid-scroll, which is expected during animation.
// Only flag if this persists after animation completes.
if (logDetail) {
NSLog(@"[WRPageVC] Fractional offset detected: %.4f (mid-scroll, monitoring)", fractional);
}
}
// ---- Check 2: Number of child view controllers ----
NSArray *childVCs = pageVC.viewControllers ?: @[];
if (childVCs.count == 0 && !methodDidScroll) {
// No view controllers while not scrolling = fault state.
s_NavDirectionFaultDetected = YES;
if (logCrashReason) {
NSLog(@"[WRPageVC] FAULT: No view controllers in page VC outside of scroll.");
}
[self _patchFaultStateForPageViewController:pageVC];
return YES;
}
// ---- Check 3: Scroll view delegate consistency ----
id scrollDelegate = scrollView.delegate;
if (scrollDelegate != pageVC) {
// The scroll view delegate should be the page view controller.
s_NavDirectionFaultDetected = YES;
if (logCrashReason) {
NSLog(@"[WRPageVC] FAULT: Scroll delegate mismatch. Expected %@, got %@",
pageVC, scrollDelegate);
}
[self _patchFaultStateForPageViewController:pageVC];
return YES;
}
return NO;
}
/// Internal method to patch a detected fault state.
+ (void)_patchFaultStateForPageViewController:(UIPageViewController *)pageVC {
// Reset the scroll view to a known state.
@try {
UIScrollView *scrollView = [pageVC valueForKey:@"_queuingScrollView"];
if (scrollView) {
// Snap the content offset to the nearest page boundary.
CGFloat frameWidth = scrollView.bounds.size.width;
CGFloat snappedX = round(scrollView.contentOffset.x / frameWidth) * frameWidth;
scrollView.contentOffset = CGPointMake(snappedX, 0);
}
} @catch (NSException *e) {
NSLog(@"[WRPageVC] Patch failed: %@", e);
}
}
///
/// Patches the navigation direction fault by swizzling the internal
/// -_navigationDirectionForQueuingScrollView: method on UIPageViewController.
///
/// The swizzled implementation returns a safe default (Forward) when the
/// internal direction value is out of range.
+ (void)patchNavigationDirectionFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// In a real implementation, this would swizzle the private method
// -[UIPageViewController _navigationDirectionForQueuingScrollView:direction:]
// to clamp the direction to a valid value.
//
// Since we can't safely swizzle private API in production, we instead:
// 1. Set a flag to enable the detection callback.
// 2. Register for UIApplicationDidEnterBackgroundNotification to
// cancel in-flight transitions.
// 3. Register for UIApplicationWillEnterForegroundNotification to
// reset state.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Cancel any in-flight transition to prevent the fault.
s_NavDirectionFaultDetected = NO;
}];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:kNavDirectionPatchedKey];
NSLog(@"[WRPageVC] Navigation direction fault patch registered.");
});
}
///
/// Patches the "no view controller managing page view" fault.
///
/// This fault occurs when UIPageViewController's internal state gets out of
/// sync with the actual view controller hierarchy, typically after:
/// - Memory warnings that cause view unloading.
/// - Rapid programmatic page changes.
/// - Interface rotation during a transition.
///
/// The patch:
/// 1. Swizzles -viewDidDisappear: on UIPageViewController to ensure
/// child VCs are properly cleaned up.
/// 2. Registers for memory warning to re-set view controllers if needed.
+ (void)patchNoViewControllerManagingPageViewFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Swizzle the private method that throws the "no view controller
// managing the view" exception. The swizzled version catches the
// exception and returns nil instead of crashing.
//
// In practice, we intercept at a higher level:
// Register for UIApplicationDidReceiveMemoryWarningNotification
// and force a re-display of the current page.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidReceiveMemoryWarningNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Post a notification for the reader to reload.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerMemoryWarning"
object:nil];
}];
NSLog(@"[WRPageVC] No-VC-managing-page-view fault patch registered.");
});
}
///
/// Patches the UIPageCurl animation fault.
///
/// The UIPageCurl transition uses a GLKView (OpenGL/Metal) internally for
/// the page curl animation. When:
/// - The app enters the background, the GL context is invalidated.
/// - A rapid sequence of curl animations is triggered (user flipping fast).
/// - Memory pressure causes the GL resources to be purged.
///
/// The result is a visual glitch (blank page, stuck curl) or a crash in
/// the rendering thread.
///
/// The patch:
/// 1. Pauses curl animations when entering background.
/// 2. Resets the curl state on foreground.
/// 3. Throttles curl animation requests.
+ (void)patchUIPageCurlFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
s_PageCurlFaultPatched = YES;
// Register for background/foreground transitions.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Disable page curl transitions while in background.
// The reader should switch to scroll mode or disable paging.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerDisableCurl"
object:nil];
}];
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationWillEnterForegroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Re-enable and force a redraw.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerEnableCurl"
object:nil];
}];
NSLog(@"[WRPageVC] UIPageCurl fault patch registered.");
});
}
// ============================================================================
#pragma mark - UIGestureRecognizerDelegate
// ============================================================================
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch:(UITouch *)touch {
// Only handle taps in the left/right edge zones for curl mode.
if (self.pageFlippingStyle != WRPageFlippingStyleCurl) return NO;
CGPoint pt = [touch locationInView:self.view];
CGFloat w = CGRectGetWidth(self.view.bounds);
// Left zone: 0..25% of width.
// Right zone: 75%..100% of width.
if (pt.x < w * 0.25 || pt.x > w * 0.75) {
return self.pagingEnabled;
}
return NO;
}
@end
@@ -0,0 +1,126 @@
//
// WRPreloadBookManager.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Preload manager. Predicts which chapters to preload based on book ranking
// and user behavior. Manages encryption keys for preloaded content.
// Supports whole-book preloading and incremental chapter preloading.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
// ---------------------------------------------------------------------------
// Preload scene types
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRPreloadScene) {
WRPreloadSceneNone = 0,
WRPreloadSceneShelf = 1, // Preload from bookshelf
WRPreloadSceneReading = 2, // Preload next chapters while reading
WRPreloadSceneWiFi = 3, // Aggressive preload on WiFi
WRPreloadSceneManual = 4, // User-initiated preload
};
// ---------------------------------------------------------------------------
// WRPreloadBookManager
// ---------------------------------------------------------------------------
@interface WRPreloadBookManager : NSObject
// --- Ivars (from binary analysis) ---
// {
// NSMutableDictionary *_preloadState; // bookId -> preload state dict
// }
@property (nonatomic, strong, readonly) NSMutableDictionary *preloadState;
#pragma mark - Class Methods: Encryption Key Storage
/// Save an encryption key for a preloaded book.
/// @param key The encryption key data (hex or raw).
/// @param path The file path where the key is associated.
/// @param bookId The book identifier.
+ (void)saveEncryptKey:(NSString *)key
forPath:(NSString *)path
bookId:(NSString *)bookId;
/// Retrieve the stored encryption key for a book.
/// @param path The associated file path.
/// @param bookId The book identifier.
/// @return The encryption key string, or nil.
+ (nullable NSString *)encryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId;
/// Remove a stored encryption key.
+ (void)removeEncryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId;
#pragma mark - Class Methods: Filename Dictionary
/// Save a filename mapping dictionary for a book.
/// Maps content keys to actual filenames in the tar archive.
+ (void)saveFileNameDict:(NSDictionary<NSString *, NSString *> *)dict
bookId:(NSString *)bookId;
/// Look up a filename by key for a given book.
+ (nullable NSString *)fileNameForKey:(NSString *)key
bookId:(NSString *)bookId;
/// Remove a filename mapping entry.
+ (void)removeFileNameForKey:(NSString *)key
bookId:(NSString *)bookId;
#pragma mark - Class Methods: Cache Management
/// Clear all preload key-value caches.
+ (void)clearKV;
/// Remove all preload data for a specific book.
+ (void)removeKVWithBookId:(NSString *)bookId;
#pragma mark - Instance Methods: Preloading
/// Preload an entire book (all chapters).
/// @param book The book model to preload.
/// @param scene The preload context/scene.
- (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene;
/// Preload chapters starting from a given index.
/// @param book The book model.
/// @param fromChapterIdx Starting chapter index.
- (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx;
/// Download specific chapters for a book.
/// @param book The book model.
/// @param uids Array of chapter UIDs to download.
- (void)downloadBook:(WRBook *)book uids:(NSArray<NSString *> *)uids;
/// Clean up all preloaded book data.
- (void)cleanUpPreloadBook;
/// Calculate preload cache size and optionally clear it.
/// @param completion Called with the total size in bytes.
/// @param onlyCalc YES to only calculate, NO to also clear.
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
onlyCalc:(BOOL)onlyCalc;
#pragma mark - Additional Methods (23 total, reconstructed)
/// Check if a book is currently being preloaded.
- (BOOL)isPreloadingBookId:(NSString *)bookId;
/// Get the preload progress for a book (0.0 - 1.0).
- (float)preloadProgressForBookId:(NSString *)bookId;
/// Cancel an ongoing preload for a book.
- (void)cancelPreloadForBookId:(NSString *)bookId;
/// Determine which chapters should be preloaded next.
- (NSArray<NSString *> *)predictedChapterUidsForBook:(WRBook *)book;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,547 @@
//
// WRPreloadBookManager.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary),
// 23 known methods, and contextual knowledge of WeRead's preload strategy.
//
// Architecture notes:
// - Class methods handle key/filename storage (shared state via NSUserDefaults
// or a static dictionary).
// - Instance methods handle the actual preloading logic.
// - Preloading decisions are based on book ranking, reading patterns,
// and network conditions (WiFi vs. cellular).
// - Preloaded content is encrypted and stored in a separate cache directory.
//
#import "WRPreloadBookManager.h"
#import "WRBookNetwork.h"
#import "WREncryptedFileManager.h"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
static NSString *const kPreloadEncryptKeyPrefix = @"preload_key_";
static NSString *const kPreloadFileNamePrefix = @"preload_fn_";
static NSString *const kPreloadCacheDir = @"preload_cache";
// Preload limits
static const NSUInteger kMaxPreloadChaptersOnWiFi = 50;
static const NSUInteger kMaxPreloadChaptersOnCellular = 5;
static const NSUInteger kMaxPreloadBooksOnShelf = 3;
#pragma mark - Private Interface
@interface WRPreloadBookManager ()
@property (nonatomic, strong, readwrite) NSMutableDictionary *preloadState;
@end
#pragma mark - Implementation
@implementation WRPreloadBookManager
{
// Ivar from binary analysis:
NSMutableDictionary *_preloadState;
}
#pragma mark - Lifecycle
- (instancetype)init
{
self = [super init];
if (self) {
_preloadState = [NSMutableDictionary dictionary];
// Register for network change notifications to adjust preload behavior
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(_networkStatusChanged:)
name:@"WRNetworkStatusChangedNotification"
object:nil];
}
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Class Methods: Encryption Key Storage
+ (void)saveEncryptKey:(NSString *)key
forPath:(NSString *)path
bookId:(NSString *)bookId
{
if (!key || !bookId) return;
// Store in NSUserDefaults with a composite key
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
kPreloadEncryptKeyPrefix, bookId,
[path lastPathComponent]];
[[NSUserDefaults standardUserDefaults] setObject:key forKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
// Also save to the keychain via WREncryptedFileManager for persistence
NSData *keyData = [key dataUsingEncoding:NSUTF8StringEncoding];
[WREncryptedFileManager saveKey:keyData forBookId:bookId];
}
+ (nullable NSString *)encryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
kPreloadEncryptKeyPrefix, bookId,
[path lastPathComponent]];
NSString *key = [[NSUserDefaults standardUserDefaults] stringForKey:storageKey];
// Fallback: try the keychain
if (!key) {
NSData *keyData = [WREncryptedFileManager keyForBookId:bookId];
if (keyData) {
key = [[NSString alloc] initWithData:keyData encoding:NSUTF8StringEncoding];
}
}
return key;
}
+ (void)removeEncryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
kPreloadEncryptKeyPrefix, bookId,
[path lastPathComponent]];
[[NSUserDefaults standardUserDefaults] removeObjectForKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
}
#pragma mark - Class Methods: Filename Dictionary
+ (void)saveFileNameDict:(NSDictionary<NSString *, NSString *> *)dict
bookId:(NSString *)bookId
{
if (!dict || !bookId) return;
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
}
+ (nullable NSString *)fileNameForKey:(NSString *)key
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:storageKey];
return dict[key];
}
+ (void)removeFileNameForKey:(NSString *)key
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
NSMutableDictionary *dict = [[[NSUserDefaults standardUserDefaults]
dictionaryForKey:storageKey] mutableCopy];
if (dict) {
[dict removeObjectForKey:key];
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
}
}
#pragma mark - Class Methods: Cache Management
+ (void)clearKV
{
// Remove all preload-related keys from NSUserDefaults
NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
NSArray *prefixes = @[kPreloadEncryptKeyPrefix, kPreloadFileNamePrefix];
for (NSString *key in defaults) {
for (NSString *prefix in prefixes) {
if ([key hasPrefix:prefix]) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}
}
}
[[NSUserDefaults standardUserDefaults] synchronize];
}
+ (void)removeKVWithBookId:(NSString *)bookId
{
if (!bookId) return;
NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
NSString *keyPrefix1 = [NSString stringWithFormat:@"%@%@", kPreloadEncryptKeyPrefix, bookId];
NSString *keyPrefix2 = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
for (NSString *key in defaults) {
if ([key hasPrefix:keyPrefix1] || [key hasPrefix:keyPrefix2]) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}
}
[[NSUserDefaults standardUserDefaults] synchronize];
}
#pragma mark - Instance Methods: Preloading
- (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene
{
// 1. Determine the maximum chapters to preload based on scene
NSUInteger maxChapters;
switch (scene) {
case WRPreloadSceneWiFi:
maxChapters = kMaxPreloadChaptersOnWiFi;
break;
case WRPreloadSceneShelf:
maxChapters = 20; // Moderate for shelf browsing
break;
case WRPreloadSceneReading:
maxChapters = 10; // Next few chapters while reading
break;
default:
maxChapters = kMaxPreloadChaptersOnCellular;
break;
}
// 2. Get the chapter list for the book
NSArray *chapters = book.chapters;
if (!chapters || chapters.count == 0) {
NSLog(@"[WRPreloadBookManager] No chapters for book %@", book.bookId);
return;
}
// 3. Select chapters to preload
NSArray *uidsToPreload = [self _selectChaptersForPreload:book
maxCount:maxChapters
scene:scene];
if (uidsToPreload.count == 0) return;
// 4. Update preload state
NSString *bookId = book.bookId;
_preloadState[bookId] = @{
@"status" : @"preloading",
@"scene" : @(scene),
@"totalCount" : @(uidsToPreload.count),
@"loadedCount" : @(0),
@"startTime" : @([[NSDate date] timeIntervalSince1970]),
};
// 5. Initiate download via WRBookNetwork
[WRBookNetwork loadTarForEpubBookId:bookId
chapters:uidsToPreload
isPreload:YES];
NSLog(@"[WRPreloadBookManager] Started preloading %lu chapters for book %@ (scene=%ld)",
(unsigned long)uidsToPreload.count, bookId, (long)scene);
}
- (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx
{
// Preload chapters starting from the given index
NSArray *chapters = book.chapters;
if (!chapters || fromChapterIdx >= (NSInteger)chapters.count) return;
// Determine how many chapters to preload ahead
NSUInteger preloadCount = 5; // Default: preload 5 chapters ahead
if ([self _isOnWiFi]) {
preloadCount = 10;
}
NSInteger startIndex = MAX(0, fromChapterIdx);
NSInteger endIndex = MIN(startIndex + (NSInteger)preloadCount,
(NSInteger)chapters.count);
NSMutableArray *uids = [NSMutableArray array];
for (NSInteger i = startIndex; i < endIndex; i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [uids addObject:uid];
}
if (uids.count > 0) {
[self downloadBook:book uids:uids];
}
}
- (void)downloadBook:(WRBook *)book uids:(NSArray<NSString *> *)uids
{
if (!book.bookId || uids.count == 0) return;
// Filter out already-downloaded chapters
NSMutableArray *pendingUIDs = [NSMutableArray array];
NSString *plainDir = [self _plainBookDirectoryForBookId:book.bookId];
for (NSString *uid in uids) {
NSString *chapterPath = [plainDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"chapter_%@.xhtml", uid]];
if (![[NSFileManager defaultManager] fileExistsAtPath:chapterPath]) {
[pendingUIDs addObject:uid];
}
}
if (pendingUIDs.count == 0) return;
// Initiate download
[WRBookNetwork loadTarForEpubBookId:book.bookId
chapters:pendingUIDs
isPreload:YES];
}
- (void)cleanUpPreloadBook
{
// 1. Remove all preloaded book files
NSString *preloadDir = [self _preloadCacheDirectory];
NSFileManager *fm = [NSFileManager defaultManager];
NSError *error = nil;
[fm removeItemAtPath:preloadDir error:&error];
if (error) {
NSLog(@"[WRPreloadBookManager] Cleanup failed: %@", error);
}
// 2. Clear all key-value caches
[[self class] clearKV];
// 3. Reset state
[_preloadState removeAllObjects];
// 4. Recreate the directory
[fm createDirectoryAtPath:preloadDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
}
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
onlyCalc:(BOOL)onlyCalc
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *preloadDir = [self _preloadCacheDirectory];
NSUInteger totalSize = [self _directorySizeAtPath:preloadDir];
if (!onlyCalc) {
// Clear the cache
[self cleanUpPreloadBook];
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(totalSize);
});
});
}
#pragma mark - Additional Methods (Reconstructed)
- (BOOL)isPreloadingBookId:(NSString *)bookId
{
NSDictionary *state = _preloadState[bookId];
return [state[@"status"] isEqualToString:@"preloading"];
}
- (float)preloadProgressForBookId:(NSString *)bookId
{
NSDictionary *state = _preloadState[bookId];
if (!state) return 0.0;
NSInteger total = [state[@"totalCount"] integerValue];
NSInteger loaded = [state[@"loadedCount"] integerValue];
if (total <= 0) return 0.0;
return (float)loaded / (float)total;
}
- (void)cancelPreloadForBookId:(NSString *)bookId
{
// Mark the preload as cancelled in state
_preloadState[bookId] = @{
@"status" : @"cancelled",
@"cancelTime": @([[NSDate date] timeIntervalSince1970]),
};
// Note: Actual network cancellation would need to be handled
// by WRBookNetwork's task management
NSLog(@"[WRPreloadBookManager] Cancelled preload for book %@", bookId);
}
- (NSArray<NSString *> *)predictedChapterUidsForBook:(WRBook *)book
{
// Predict which chapters the user is likely to read next.
//
// Strategy:
// 1. If the user is currently reading chapter N, predict N+1, N+2, ...
// 2. If the user has a pattern of jumping (e.g., to bookmarks), preload those
// 3. Consider book ranking: popular books get more aggressive preloading
NSMutableArray *predicted = [NSMutableArray array];
NSArray *chapters = book.chapters;
if (!chapters) return predicted;
// Find the current chapter index
NSInteger currentIndex = 0;
NSString *currentUid = book.currentChapterUid;
for (NSUInteger i = 0; i < chapters.count; i++) {
NSDictionary *chapter = chapters[i];
if ([chapter[@"chapterUid"] isEqualToString:currentUid]) {
currentIndex = (NSInteger)i;
break;
}
}
// Predict the next N chapters
NSUInteger predictCount = [self _isOnWiFi] ? 10 : 3;
for (NSInteger i = currentIndex + 1;
i < MIN(currentIndex + 1 + (NSInteger)predictCount, (NSInteger)chapters.count);
i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [predicted addObject:uid];
}
return [predicted copy];
}
#pragma mark - Private Helpers
- (NSArray<NSString *> *)_selectChaptersForPreload:(WRBook *)book
maxCount:(NSUInteger)maxCount
scene:(WRPreloadScene)scene
{
NSArray *chapters = book.chapters;
if (!chapters) return @[];
NSMutableArray *selected = [NSMutableArray array];
NSString *currentUid = book.currentChapterUid;
NSInteger currentIndex = 0;
// Find current chapter index
for (NSUInteger i = 0; i < chapters.count; i++) {
NSDictionary *chapter = chapters[i];
if ([chapter[@"chapterUid"] isEqualToString:currentUid]) {
currentIndex = (NSInteger)i;
break;
}
}
// Select chapters based on scene
switch (scene) {
case WRPreloadSceneReading:
// Preload next chapters from current position
for (NSInteger i = currentIndex;
i < MIN(currentIndex + (NSInteger)maxCount, (NSInteger)chapters.count);
i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
}
break;
case WRPreloadSceneShelf:
// Preload from the beginning (user might start reading)
for (NSUInteger i = 0; i < MIN(maxCount, chapters.count); i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
}
break;
case WRPreloadSceneWiFi:
// Aggressive: preload all chapters
for (NSDictionary *chapter in chapters) {
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
if (selected.count >= maxCount) break;
}
break;
default:
// Minimal: just the next chapter
if (currentIndex + 1 < (NSInteger)chapters.count) {
NSDictionary *chapter = chapters[currentIndex + 1];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
}
break;
}
return [selected copy];
}
- (BOOL)_isOnWiFi
{
// Check network status
// In the actual app, this uses SCNetworkReachability or a wrapper
// For reconstruction, assume WiFi if not explicitly cellular
NSNumber *isWiFi = [[NSUserDefaults standardUserDefaults] objectForKey:@"WRIsOnWiFi"];
return isWiFi ? isWiFi.boolValue : YES; // Default to WiFi for safety
}
- (void)_networkStatusChanged:(NSNotification *)notification
{
// Adjust preload behavior when network changes
BOOL isWiFi = [notification.userInfo[@"isWiFi"] boolValue];
if (!isWiFi) {
// On cellular: cancel aggressive preloads
for (NSString *bookId in [_preloadState copy]) {
NSDictionary *state = _preloadState[bookId];
if ([state[@"scene"] integerValue] == WRPreloadSceneWiFi) {
[self cancelPreloadForBookId:bookId];
}
}
}
}
- (NSString *)_preloadCacheDirectory
{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
return [caches stringByAppendingPathComponent:kPreloadCacheDir];
}
- (NSString *)_plainBookDirectoryForBookId:(NSString *)bookId
{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
return [[caches stringByAppendingPathComponent:@"plain_books"]
stringByAppendingPathComponent:bookId];
}
- (NSUInteger)_directorySizeAtPath:(NSString *)path
{
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0;
if (!isDir) {
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil];
return [attrs fileSize];
}
NSUInteger totalSize = 0;
NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path];
for (NSString *filename in enumerator) {
NSString *filePath = [path stringByAppendingPathComponent:filename];
NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
totalSize += [attrs fileSize];
}
return totalSize;
}
@end
@@ -0,0 +1,158 @@
//
// WRReaderPencilNoteManager.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Apple Pencil note manager. 11 class methods identified from binary.
// Stores drawings locally, uploads to Tencent Cloud COS.
// Supports draft and published states for pencil annotations.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
// ---------------------------------------------------------------------------
// Pencil drawing color styles
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRPencilColorStyle) {
WRPencilColorStyleBlack = 0,
WRPencilColorStyleGray = 1,
WRPencilColorStyleRed = 2,
WRPencilColorStyleBlue = 3,
WRPencilColorStyleYellow = 4,
WRPencilColorStyleGreen = 5,
WRPencilColorStylePencil = 6, // Natural pencil
WRPencilColorStylePen = 7, // Pen/fountain
WRPencilColorStyleMarker = 8, // Highlighter marker
};
// ---------------------------------------------------------------------------
// Upload callback
// ---------------------------------------------------------------------------
typedef void (^WRPencilUploadCallback)(BOOL success,
NSString * _Nullable imageUrl,
NSString * _Nullable drawingUrl,
NSError * _Nullable error);
// ---------------------------------------------------------------------------
// Download callback
// ---------------------------------------------------------------------------
typedef void (^WRPencilDownloadCallback)(BOOL success,
NSData * _Nullable drawingData,
NSError * _Nullable error);
// ---------------------------------------------------------------------------
// WRReaderPencilNoteManager
// ---------------------------------------------------------------------------
@interface WRReaderPencilNoteManager : NSObject
#pragma mark - Drawing Existence Check
/// Check whether a pencil drawing exists locally for the given review item.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES to check for draft drawings, NO for published.
/// @return YES if the drawing file exists on disk.
+ (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
#pragma mark - Delete Operations
/// Delete all locally stored review drawings.
+ (void)deleteAllReviewDrawings;
/// Delete a specific drawing.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES to delete the draft, NO for published.
+ (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
#pragma mark - Download
/// Download drawing data from Tencent Cloud COS.
/// @param cosUrl The COS URL for the drawing data.
/// @param destPath Local destination path.
/// @param callback Called with the downloaded data or error.
+ (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl
desPath:(NSString *)destPath
callback:(WRPencilDownloadCallback)callback;
#pragma mark - File Paths
/// Return the base directory for pencil drawing files.
+ (NSString *)drawingFileDirectory;
/// Return the file path for a specific drawing.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES for draft path, NO for published path.
+ (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
/// Return the file path for a drawing's rendered image.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
+ (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId;
#pragma mark - Upload
/// Upload a pencil drawing image and data to Tencent Cloud COS.
/// @param drawing The rendered UIImage of the drawing.
/// @param colorStyle The color/style used for the drawing.
/// @param uploadImage YES to upload the image, NO for data only.
/// @param canRetry YES if the upload can be retried on failure.
+ (void)uploadPencilDrawing:(UIImage *)drawing
colorStyle:(WRPencilColorStyle)colorStyle
onlyUploadImage:(BOOL)uploadImage
canRetry:(BOOL)canRetry;
/// Upload pencil note raw data (PKDrawing serialized data).
/// @param noteData The serialized drawing data.
/// @param suffix File suffix/extension (e.g., "drawing", "png").
+ (void)uploadPencilNoteData:(NSData *)noteData
suffix:(NSString *)suffix;
#pragma mark - Local Storage
/// Write raw drawing data to local storage.
/// @param data The drawing data to write.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES for draft, NO for published.
+ (void)writeDrawingDataToLocal:(NSData *)data
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
/// Write a rendered drawing image to local storage.
/// @param drawing The UIImage to write.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES for draft, NO for published.
+ (void)writeDrawingToLocal:(UIImage *)drawing
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
#pragma mark - Additional Methods (Reconstructed)
/// Return the total size of all stored pencil drawings in bytes.
+ (NSUInteger)totalDrawingStorageSize;
/// Return a list of all locally stored drawing review item IDs.
+ (NSArray<NSString *> *)allStoredDrawingReviewItemIds;
/// Render a PKDrawing data to UIImage.
+ (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData
scale:(CGFloat)scale;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,526 @@
//
// WRReaderPencilNoteManager.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 11 class methods identified from binary. Manages Apple Pencil
// annotations with local storage and Tencent Cloud COS uploads.
//
// Architecture notes:
// - All methods are class methods (static utility pattern).
// - Drawings are stored as both raw PKDrawing data and rendered PNG images.
// - Draft vs. published states allow users to save work-in-progress.
// - Uploads go to Tencent Cloud COS (Cloud Object Storage) via
// signed URLs or direct API.
// - File paths follow a predictable pattern: {directory}/{reviewItemId}_{reviewId}.{ext}
//
#import "WRReaderPencilNoteManager.h"
#import <PencilKit/PencilKit.h>
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
static NSString *const kPencilDrawingDir = @"pencil_drawings";
static NSString *const kPencilImageDir = @"pencil_images";
static NSString *const kCOSUploadURL = @"https://weread-1258476243.file.myqcloud.com";
static NSString *const kDraftSuffix = @"_draft";
static NSString *const kPublishedSuffix = @"";
#pragma mark - Implementation
@implementation WRReaderPencilNoteManager
#pragma mark - Drawing Existence Check
+ (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
// Check for the drawing data file
NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId
reviewId:reviewId
isDraft:isDraft];
BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:dataPath];
if (!exists) {
// Also check for the image file
NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId
reviewId:reviewId];
exists = [[NSFileManager defaultManager] fileExistsAtPath:imagePath];
}
return exists;
}
#pragma mark - Delete Operations
+ (void)deleteAllReviewDrawings
{
// Remove the entire pencil drawings directory
NSString *drawingDir = [self drawingFileDirectory];
NSString *imageDir = [self _pencilImageDirectory];
NSFileManager *fm = [NSFileManager defaultManager];
// Remove drawing data
[fm removeItemAtPath:drawingDir error:nil];
[fm removeItemAtPath:imageDir error:nil];
// Recreate empty directories
[fm createDirectoryAtPath:drawingDir withIntermediateDirectories:YES
attributes:nil error:nil];
[fm createDirectoryAtPath:imageDir withIntermediateDirectories:YES
attributes:nil error:nil];
NSLog(@"[WRReaderPencilNoteManager] Deleted all review drawings");
}
+ (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
NSFileManager *fm = [NSFileManager defaultManager];
// Delete drawing data file
NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId
reviewId:reviewId
isDraft:isDraft];
[fm removeItemAtPath:dataPath error:nil];
// Delete image file (only for published, drafts may not have images)
if (!isDraft) {
NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId
reviewId:reviewId];
[fm removeItemAtPath:imagePath error:nil];
}
NSLog(@"[WRReaderPencilNoteManager] Deleted drawing: %@ (draft=%d)",
reviewItemId, isDraft);
}
#pragma mark - Download
+ (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl
desPath:(NSString *)destPath
callback:(WRPencilDownloadCallback)callback
{
if (!cosUrl || cosUrl.length == 0) {
if (callback) {
callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Empty COS URL"}]);
}
return;
}
NSURL *url = [NSURL URLWithString:cosUrl];
if (!url) {
if (callback) {
callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager"
code:-2
userInfo:@{NSLocalizedDescriptionKey: @"Invalid COS URL"}]);
}
return;
}
// Download from COS
NSURLSessionConfiguration *config =
[NSURLSessionConfiguration defaultSessionConfiguration];
config.timeoutIntervalForRequest = 30;
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
NSURLSessionDataTask *task = [session dataTaskWithURL:url
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error || !data) {
NSLog(@"[WRReaderPencilNoteManager] COS download failed: %@",
error.localizedDescription);
if (callback) callback(NO, nil, error);
return;
}
// Verify HTTP status
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode != 200) {
NSError *statusError = [NSError errorWithDomain:@"WRReaderPencilNoteManager"
code:httpResponse.statusCode
userInfo:@{NSLocalizedDescriptionKey:
[NSString stringWithFormat:@"HTTP %ld",
(long)httpResponse.statusCode]}];
if (callback) callback(NO, nil, statusError);
return;
}
// Save to local destination
if (destPath) {
NSString *destDir = [destPath stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:destDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSError *writeError = nil;
[data writeToFile:destPath options:NSDataWritingAtomic error:&writeError];
if (writeError) {
NSLog(@"[WRReaderPencilNoteManager] Write failed: %@", writeError);
}
}
if (callback) callback(YES, data, nil);
}];
[task resume];
}
#pragma mark - File Paths
+ (NSString *)drawingFileDirectory
{
static NSString *sDir = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
sDir = [caches stringByAppendingPathComponent:kPencilDrawingDir];
// Create directory if needed
[[NSFileManager defaultManager] createDirectoryAtPath:sDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
});
return sDir;
}
+ (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
NSString *dir = [self drawingFileDirectory];
NSString *suffix = isDraft ? kDraftSuffix : kPublishedSuffix;
// Filename: {reviewItemId}_{reviewId}{suffix}.drawing
NSString *filename = [NSString stringWithFormat:@"%@_%@%@.drawing",
reviewItemId, reviewId, suffix];
return [dir stringByAppendingPathComponent:filename];
}
+ (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
{
NSString *dir = [self _pencilImageDirectory];
// Filename: {reviewItemId}_{reviewId}.png
NSString *filename = [NSString stringWithFormat:@"%@_%@.png",
reviewItemId, reviewId];
return [dir stringByAppendingPathComponent:filename];
}
#pragma mark - Upload
+ (void)uploadPencilDrawing:(UIImage *)drawing
colorStyle:(WRPencilColorStyle)colorStyle
onlyUploadImage:(BOOL)uploadImage
canRetry:(BOOL)canRetry
{
if (!drawing) return;
// 1. Render the image to PNG data
NSData *imageData = UIImagePNGRepresentation(drawing);
if (!imageData) return;
// 2. Build the upload request to COS
//
// Tencent Cloud COS upload flow:
// a. Request a signed upload URL from WeRead's backend
// b. PUT the file directly to COS using the signed URL
//
// For simplicity, we'll show the direct upload pattern.
NSString *filename = [NSString stringWithFormat:@"pencil_%@_%ld.png",
[[NSUUID UUID] UUIDString],
(long)[[NSDate date] timeIntervalSince1970]];
NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@",
kCOSUploadURL, filename];
NSURL *uploadURL = [NSURL URLWithString:uploadURLStr];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL];
[request setHTTPMethod:@"PUT"];
[request addValue:@"image/png" forHTTPHeaderField:@"Content-Type"];
[request addValue:@(imageData.length).stringValue forHTTPHeaderField:@"Content-Length"];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionUploadTask *task = [session uploadTaskWithRequest:request
fromData:imageData
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Upload failed: %@",
error.localizedDescription);
if (canRetry) {
// Queue for retry
[self _queueRetryUpload:imageData filename:filename];
}
return;
}
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode == 200 || httpResponse.statusCode == 201) {
NSLog(@"[WRReaderPencilNoteManager] Upload success: %@", filename);
// Notify the backend about the uploaded file
[self _notifyBackendOfFileUpload:uploadURLStr
colorStyle:colorStyle];
}
}];
[task resume];
// 3. Also upload the raw drawing data if requested
if (!uploadImage) {
// The drawing data (PKDrawing serialized) needs separate upload
// This is handled by uploadPencilNoteData:suffix:
}
}
+ (void)uploadPencilNoteData:(NSData *)noteData
suffix:(NSString *)suffix
{
if (!noteData || noteData.length == 0) return;
NSString *filename = [NSString stringWithFormat:@"pencil_data_%@_%@.%@",
[[NSUUID UUID] UUIDString],
@((long)[[NSDate date] timeIntervalSince1970]),
suffix ?: @"drawing"];
NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@",
kCOSUploadURL, filename];
NSURL *uploadURL = [NSURL URLWithString:uploadURLStr];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL];
[request setHTTPMethod:@"PUT"];
[request addValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
[request addValue:@(noteData.length).stringValue forHTTPHeaderField:@"Content-Length"];
NSURLSessionUploadTask *task = [[NSURLSession sharedSession]
uploadTaskWithRequest:request
fromData:noteData
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Data upload failed: %@",
error.localizedDescription);
return;
}
NSLog(@"[WRReaderPencilNoteManager] Data upload success: %@", filename);
}];
[task resume];
}
#pragma mark - Local Storage
+ (void)writeDrawingDataToLocal:(NSData *)data
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
if (!data || !reviewItemId || !reviewId) return;
NSString *path = [self drawingFilePathWithReviewItemId:reviewItemId
reviewId:reviewId
isDraft:isDraft];
// Ensure directory exists
NSString *dir = [path stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSError *error = nil;
[data writeToFile:path options:NSDataWritingAtomic error:&error];
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Write drawing data failed: %@", error);
}
}
+ (void)writeDrawingToLocal:(UIImage *)drawing
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
if (!drawing || !reviewItemId || !reviewId) return;
NSString *path = [self imageFilePathWithReviewItemId:reviewItemId
reviewId:reviewId];
// Ensure directory exists
NSString *dir = [path stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSData *pngData = UIImagePNGRepresentation(drawing);
if (pngData) {
NSError *error = nil;
[pngData writeToFile:path options:NSDataWritingAtomic error:&error];
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Write drawing image failed: %@", error);
}
}
}
#pragma mark - Additional Methods
+ (NSUInteger)totalDrawingStorageSize
{
NSFileManager *fm = [NSFileManager defaultManager];
NSUInteger totalSize = 0;
// Calculate size of drawing data directory
totalSize += [self _directorySize:[self drawingFileDirectory]];
// Calculate size of image directory
totalSize += [self _directorySize:[self _pencilImageDirectory]];
return totalSize;
}
+ (NSArray<NSString *> *)allStoredDrawingReviewItemIds
{
NSString *drawingDir = [self drawingFileDirectory];
NSFileManager *fm = [NSFileManager defaultManager];
NSError *error = nil;
NSArray *files = [fm contentsOfDirectoryAtPath:drawingDir error:&error];
if (!files) return @[];
NSMutableSet *reviewItemIds = [NSMutableSet set];
for (NSString *filename in files) {
// Filename format: {reviewItemId}_{reviewId}[_draft].drawing
NSArray *components = [filename componentsSeparatedByString:@"_"];
if (components.count >= 1) {
[reviewItemIds addObject:components[0]];
}
}
return [reviewItemIds allObjects];
}
+ (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData
scale:(CGFloat)scale
{
if (!drawingData || drawingData.length == 0) return nil;
// Attempt to deserialize as PKDrawing
if (@available(iOS 13.0, *)) {
NSError *error = nil;
PKDrawing *drawing = [[PKDrawing alloc] initWithData:drawingData error:&error];
if (drawing) {
CGRect bounds = drawing.bounds;
if (scale <= 0) scale = [UIScreen mainScreen].scale;
UIImage *image = [drawing imageFromRect:bounds scale:scale];
return image;
}
}
// Fallback: try UIImage from data directly
return [UIImage imageWithData:drawingData];
}
#pragma mark - Private Helpers
+ (NSString *)_pencilImageDirectory
{
static NSString *sDir = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
sDir = [caches stringByAppendingPathComponent:kPencilImageDir];
[[NSFileManager defaultManager] createDirectoryAtPath:sDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
});
return sDir;
}
+ (NSUInteger)_directorySize:(NSString *)path
{
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0;
if (!isDir) {
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil];
return [attrs fileSize];
}
NSUInteger totalSize = 0;
NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path];
for (NSString *filename in enumerator) {
NSString *filePath = [path stringByAppendingPathComponent:filename];
NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
totalSize += [attrs fileSize];
}
return totalSize;
}
+ (void)_queueRetryUpload:(NSData *)imageData filename:(NSString *)filename
{
// Store failed upload in a retry queue
NSMutableArray *queue = [[[NSUserDefaults standardUserDefaults]
arrayForKey:@"pencil_upload_retry_queue"] mutableCopy] ?: [NSMutableArray array];
NSDictionary *entry = @{
@"filename" : filename ?: @"",
@"timestamp" : @((long)[[NSDate date] timeIntervalSince1970]),
// Note: imageData is too large for UserDefaults; would use file-based queue
};
[queue addObject:entry];
[[NSUserDefaults standardUserDefaults] setObject:queue forKey:@"pencil_upload_retry_queue"];
[[NSUserDefaults standardUserDefaults] synchronize];
// Write the actual image data to a retry file
NSString *retryDir = [[self drawingFileDirectory]
stringByAppendingPathComponent:@"retry"];
[[NSFileManager defaultManager] createDirectoryAtPath:retryDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSString *retryPath = [retryDir stringByAppendingPathComponent:filename];
[imageData writeToFile:retryPath atomically:YES];
}
+ (void)_notifyBackendOfFileUpload:(NSString *)fileURL
colorStyle:(WRPencilColorStyle)colorStyle
{
// Notify WeRead's backend that a pencil drawing has been uploaded to COS.
// The backend will associate it with the review/note.
//
// This would typically be a POST to an API endpoint with the COS URL.
NSLog(@"[WRReaderPencilNoteManager] Notified backend of upload: %@ (style=%ld)",
fileURL, (long)colorStyle);
}
@end
@@ -0,0 +1,211 @@
//
// WRReaderViewController.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Main reader controller. Manages reading state, progress saving,
// chapter jumping, page rendering lifecycle, and the typesetter.
// 431 methods total — this header exposes the key public interface.
//
#import <UIKit/UIKit.h>
#import "WRPageViewController.h"
@class WRBook;
@class WRChapterData;
@class WRChapterPageCount;
@class WRPageView;
@class WRReadingProgress;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - Reading Progress Data
// ============================================================================
/// Encapsulates the current reading position.
@interface WRReadingProgress : NSObject
@property (nonatomic, copy, nullable) NSString *bookId;
@property (nonatomic, assign) NSUInteger chapterIndex; // Current chapter (0-based).
@property (nonatomic, assign) NSUInteger pageIndex; // Page within chapter (0-based).
@property (nonatomic, assign) NSUInteger charIndex; // Character offset within chapter.
@property (nonatomic, assign) CGFloat scrollOffset; // For scroll-based reading.
@property (nonatomic, copy, nullable) NSString *chapterId;
@property (nonatomic, assign) double readPercentage; // 0.0 .. 1.0.
@end
// ============================================================================
#pragma mark - WRReaderViewControllerDelegate
// ============================================================================
@protocol WRReaderViewControllerDelegate <NSObject>
@optional
/// Called when the reading progress changes (page flip, chapter jump).
- (void)readerViewController:(WRReaderViewController *)readerVC
didUpdateProgress:(WRReadingProgress *)progress;
/// Called when the reader needs to present a modal (e.g., settings, TOC).
- (void)readerViewController:(WRReaderViewController *)readerVC
presentViewController:(UIViewController *)viewController
animated:(BOOL)animated
completion:(void (^ __nullable)(void))completion;
/// Called when the reader exits.
- (void)readerViewControllerDidClose:(WRReaderViewController *)readerVC;
@end
// ============================================================================
#pragma mark - WRReaderViewController
// ============================================================================
@interface WRReaderViewController : UIViewController <WRPageViewControllerDelegate>
// ---- Delegate ----
@property (nonatomic, weak, nullable) id<WRReaderViewControllerDelegate> readerDelegate;
// ---- Book & Progress ----
/// The book being read.
@property (nonatomic, strong, readonly, nullable) WRBook *book;
/// The book identifier (convenience accessor).
@property (nonatomic, copy, readonly, nullable) NSString *bookId;
/// Current reading progress.
@property (nonatomic, strong, nullable) WRReadingProgress *readingProgress;
// ---- Chapter State ----
/// The chapter data for the currently loaded chapter.
@property (nonatomic, strong, nullable) WRChapterData *currentChapterData;
/// The page count calculator for the current chapter.
@property (nonatomic, strong, nullable) WRChapterPageCount *currentChapterPageCount;
/// The total number of chapters in the book.
@property (nonatomic, assign, readonly) NSUInteger totalChapters;
// ---- Reader Mode ----
/// Whether the reader is in doodle/drawing mode (for handwritten notes).
@property (nonatomic, assign, readonly) BOOL doodleMode;
/// Whether auto-read is active.
@property (nonatomic, assign, readonly) BOOL autoReadEnabled;
// ---- Typesetter ----
/// Whether the typesetter is currently recomposing (re-layout).
@property (nonatomic, assign, readonly) BOOL isRecomposing;
// ============================================================================
#pragma mark - Initialization
// ============================================================================
/// Full initialization with all options.
/// @param book The book model object.
/// @param progress Initial reading progress.
/// @param forceUseInitialProgress If YES, ignore any saved progress and use the given one.
/// @param doodleMode Start in doodle mode.
/// @param autoRead Start with auto-read enabled.
- (instancetype)initWithBook:(WRBook *)book
progress:(WRReadingProgress * __nullable)progress
forceUseInitialProgress:(BOOL)forceUseInitialProgress
doodleMode:(BOOL)doodleMode
autoRead:(BOOL)autoRead;
/// Convenience init with just a book ID (loads book data from cache/server).
- (instancetype)initWithBookId:(NSString *)bookId;
// ============================================================================
#pragma mark - Lifecycle
// ============================================================================
- (void)viewDidLoad;
// ============================================================================
#pragma mark - Page Rendering
// ============================================================================
/// Renders the page view for the given progress data. This is the main
/// entry point for displaying a page:
/// 1. Retrieves or computes the chapter data.
/// 2. Computes pagination for the current page size and typesetter settings.
/// 3. Creates/updates the WRPageView with the layout frame for the page range.
/// 4. Updates the page view controller's child.
///
/// @param pageView The page view to render into.
/// @param progressData The reading progress (chapter + page index).
/// @param source A string identifying the caller (for logging).
- (void)renderPageView:(WRPageView *)pageView
progressData:(WRReadingProgress *)progressData
source:(NSString *)source;
/// Recomposes (re-typesets) the current page view.
/// Called after font size, line spacing, or theme changes.
/// @param source A string identifying the caller.
- (void)recomposeCurrentPageViewWithSource:(NSString *)source;
/// Reloads all page views with new progress data.
/// Called after a full chapter change or settings change.
/// @param progressData The new reading progress.
/// @param source A string identifying the caller.
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
source:(NSString *)source;
// ============================================================================
#pragma mark - Chapter Navigation
// ============================================================================
/// Jumps to a specific chapter and position.
/// @param chapterIdx The chapter index (0-based).
/// @param position The page index within the chapter.
/// @param positionOfFile The character offset within the chapter (for precise positioning).
- (void)gotoChapterIdx:(NSUInteger)chapterIdx
position:(NSUInteger)position
positionOfFile:(NSUInteger)positionOfFile;
/// Advances to the next chapter. Resets page index to 0.
- (void)jumpReadingToNextChapter;
/// Goes back to the previous chapter. Sets page index to the last page.
- (void)jumpReadingToPreChapter;
// ============================================================================
#pragma mark - Page Flip Callback
// ============================================================================
/// Called by WRPageViewController when a page flip completes.
/// Updates progress, saves state, and triggers prefetching of adjacent chapters.
- (void)didFlipPage;
// ============================================================================
#pragma mark - Progress Persistence
// ============================================================================
/// Saves the current reading progress.
/// @param isAsync If YES, save asynchronously (non-blocking). If NO, save synchronously.
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync;
// ============================================================================
#pragma mark - Typesetter
// ============================================================================
/// Modifies the typesetter attributes (font, size, spacing, etc.) and
/// triggers a full re-typeset.
/// @param block A block that receives a mutable dictionary of typesetter
/// attributes and modifies them in place.
- (void)changeTypesetterAttributesWithBlock:(void (^ __nonnull)(NSMutableDictionary *attrs))block;
// ============================================================================
#pragma mark - Pagination
// ============================================================================
/// Initializes or re-initializes the chapter page count calculator.
/// Called when the chapter changes, the view size changes, or typesetter
/// settings change.
- (void)initChapterPageCount;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,876 @@
//
// WRReaderViewController.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the main reader controller.
// 431 methods total. This file covers the key methods for page rendering,
// chapter navigation, progress saving, and typesetter management.
//
#import "WRReaderViewController.h"
#import "WRPageViewController.h"
#import "WRPageView.h"
#import "WRChapterData.h"
#import "WRChapterPageCount.h"
#import "WRCoreTextLayouter.h"
#import "WRCoreTextLayoutFrame.h"
#import "WRBook.h"
// ============================================================================
#pragma mark - Constants
// ============================================================================
static NSString *const kReadingProgressKeyPrefix = @"weread_progress_";
static NSString *const kChapterCachePrefix = @"weread_chapter_";
static NSString *const kPageCountCachePrefix = @"weread_pagecount_";
static NSString *const kReaderDidFlipPage = @"WRReaderDidFlipPage";
static NSString *const kReaderChapterLoaded = @"WRReaderChapterLoaded";
static NSString *const kReaderProgressSaved = @"WRReaderProgressSaved";
// ============================================================================
#pragma mark - WRReadingProgress
// ============================================================================
@implementation WRReadingProgress
- (instancetype)init {
self = [super init];
if (self) {
_chapterIndex = 0;
_pageIndex = 0;
_charIndex = 0;
_scrollOffset = 0.0;
_readPercentage = 0.0;
}
return self;
}
- (instancetype)copyWithZone:(NSZone *)zone {
WRReadingProgress *copy = [[WRReadingProgress alloc] init];
copy.bookId = self.bookId;
copy.chapterIndex = self.chapterIndex;
copy.pageIndex = self.pageIndex;
copy.charIndex = self.charIndex;
copy.scrollOffset = self.scrollOffset;
copy.chapterId = self.chapterId;
copy.readPercentage = self.readPercentage;
return copy;
}
@end
// ============================================================================
#pragma mark - WRReaderViewController ()
// ============================================================================
@interface WRReaderViewController ()
@property (nonatomic, strong) WRBook *book;
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, strong) WRPageViewController *pageViewController;
@property (nonatomic, assign) BOOL doodleMode;
@property (nonatomic, assign) BOOL autoReadEnabled;
@property (nonatomic, assign) BOOL isRecomposing;
@property (nonatomic, assign) BOOL forceUseInitialProgress;
@property (nonatomic, strong) WRReadingProgress *initialProgress;
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, WRChapterData *> *chapterDataCache;
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *pageCountCache;
@property (nonatomic, strong) NSMutableArray<WRPageView *> *activePageViews;
@property (nonatomic, strong) dispatch_queue_t chapterLoadQueue;
@property (nonatomic, assign) BOOL isLoadingChapter;
@property (nonatomic, assign) NSUInteger loadRetryCount;
@property (nonatomic, assign) NSUInteger maxRetryCount;
@property (nonatomic, strong) NSTimer *autoReadTimer;
@property (nonatomic, strong) NSTimer *progressSaveTimer;
@end
// ============================================================================
#pragma mark - WRReaderViewController Implementation
// ============================================================================
@implementation WRReaderViewController
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)initWithBook:(WRBook *)book
progress:(WRReadingProgress *)progress
forceUseInitialProgress:(BOOL)forceUseInitialProgress
doodleMode:(BOOL)doodleMode
autoRead:(BOOL)autoRead {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_book = book;
_bookId = book.bookId;
_initialProgress = progress;
_forceUseInitialProgress = forceUseInitialProgress;
_doodleMode = doodleMode;
_autoReadEnabled = autoRead;
_maxRetryCount = 3;
_loadRetryCount = 0;
_chapterDataCache = [NSMutableDictionary dictionary];
_pageCountCache = [NSMutableDictionary dictionary];
_activePageViews = [NSMutableArray array];
_chapterLoadQueue = dispatch_queue_create(
"com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
if (forceUseInitialProgress && progress) {
_readingProgress = [progress copy];
} else {
_readingProgress = [self _loadSavedProgress] ?: progress;
}
if (!_readingProgress) {
_readingProgress = [[WRReadingProgress alloc] init];
_readingProgress.bookId = _bookId;
}
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(_handleMemoryWarning:)
name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(_handleAutoReadAdvance:)
name:@"WRPageViewAutoReadAdvance" object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(_handleFontSizeChange:)
name:@"WRPageViewFontSizeChange" object:nil];
}
return self;
}
- (instancetype)initWithBookId:(NSString *)bookId {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_bookId = bookId;
_maxRetryCount = 3;
_chapterDataCache = [NSMutableDictionary dictionary];
_pageCountCache = [NSMutableDictionary dictionary];
_activePageViews = [NSMutableArray array];
_chapterLoadQueue = dispatch_queue_create(
"com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
[self _loadBookDataWithBookId:bookId];
}
return self;
}
- (void)dealloc {
[_autoReadTimer invalidate];
[_progressSaveTimer invalidate];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - View Lifecycle
// ============================================================================
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
// Create and embed the page view controller.
WRPageFlippingStyle flipStyle = [self _userPreferredFlipStyle];
UIPageViewControllerTransitionStyle uiStyle =
(flipStyle == WRPageFlippingStyleCurl)
? UIPageViewControllerTransitionStylePageCurl
: UIPageViewControllerTransitionStyleScroll;
self.pageViewController =
[[WRPageViewController alloc] initWithDelegate:self
withPageType:uiStyle
pageFlippingStyle:flipStyle];
[self addChildViewController:self.pageViewController];
self.pageViewController.view.frame = self.view.bounds;
self.pageViewController.view.autoresizingMask =
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.pageViewController.view];
[self.pageViewController didMoveToParentViewController:self];
// Load the initial chapter.
[self _loadChapterAtIndex:self.readingProgress.chapterIndex
completion:^(WRChapterData *chapterData, NSError *error) {
if (chapterData) {
[self _displayChapter:chapterData
atPageIndex:self.readingProgress.pageIndex
animated:NO];
} else {
[self _showChapterLoadError:error];
}
}];
// Start the progress save timer (every 30 seconds).
_progressSaveTimer =
[NSTimer scheduledTimerWithTimeInterval:30.0
target:self
selector:@selector(_periodicProgressSave)
userInfo:nil
repeats:YES];
if (_autoReadEnabled) {
[self _startAutoRead];
}
}
// ============================================================================
#pragma mark - Page Rendering
// ============================================================================
///
/// The main rendering pipeline:
/// 1. Look up or compute WRChapterData for the progress's chapter.
/// 2. Compute pagination via WRChapterPageCount.
/// 3. Get the character range for the target page.
/// 4. Create a WRCoreTextLayoutFrame for that range.
/// 5. Assign the layout frame to the WRPageView.
/// 6. Trigger -setNeedsDisplay on the page view.
///
- (void)renderPageView:(WRPageView *)pageView
progressData:(WRReadingProgress *)progressData
source:(NSString *)source {
if (!pageView || !progressData) return;
NSLog(@"[WRReader] renderPageView source=%@ ch=%lu page=%lu",
source, (unsigned long)progressData.chapterIndex,
(unsigned long)progressData.pageIndex);
// Step 1: Get or compute chapter data.
WRChapterData *chapterData = [self _chapterDataForIndex:progressData.chapterIndex];
if (!chapterData) {
[pageView showLoadingWithProgress:0.0];
return;
}
// Step 2: Compute pagination.
WRChapterPageCount *pageCount = [self _pageCountForChapterData:chapterData];
if (!pageCount || pageCount.totalPages == 0) {
[pageView showErrorWithMessage:@"页面计算失败"];
return;
}
// Step 3: Get the page range.
NSUInteger pageIdx = progressData.pageIndex;
if (pageIdx >= pageCount.totalPages) {
pageIdx = pageCount.totalPages - 1;
}
NSRange pageRange = [pageCount rangeForPageAtIndex:pageIdx];
if (pageRange.location == NSNotFound) {
[pageView showErrorWithMessage:@"页面范围无效"];
return;
}
// Step 4: Create layout frame for this page range.
WRCoreTextLayouter *layouter = chapterData.layouter;
WRCoreTextLayoutFrame *layoutFrame =
[layouter layoutFrameForRange:pageRange
size:self.view.bounds.size
insets:chapterData.contentInsets];
// Step 5: Assign to page view.
pageView.chapterData = chapterData;
pageView.layoutFrame = layoutFrame;
pageView.pageIndex = pageIdx;
// Step 6: Trigger redraw.
[pageView setNeedsDisplay];
// Step 7: Update friend reviews button.
[pageView friendReviewsCount:0];
// Step 8: Prefetch adjacent chapters.
[self _prefetchAdjacentChaptersForIndex:progressData.chapterIndex];
}
/// Recomposes the current page view after a typesetter change.
- (void)recomposeCurrentPageViewWithSource:(NSString *)source {
self.isRecomposing = YES;
NSLog(@"[WRReader] recomposeCurrentPageView source=%@", source);
[self.pageCountCache removeAllObjects];
WRChapterData *chapterData = self.currentChapterData;
if (chapterData) {
[self _reTypesetChapterData:chapterData];
}
[self renderPageView:self.activePageViews.firstObject
progressData:self.readingProgress
source:source];
self.isRecomposing = NO;
}
/// Reloads all page views after a full settings change.
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
source:(NSString *)source {
[self.chapterDataCache removeAllObjects];
[self.pageCountCache removeAllObjects];
self.readingProgress = progressData;
[self _loadChapterAtIndex:progressData.chapterIndex
completion:^(WRChapterData *chapterData, NSError *error) {
if (chapterData) {
[self _displayChapter:chapterData
atPageIndex:progressData.pageIndex
animated:NO];
}
}];
}
// ============================================================================
#pragma mark - Chapter Navigation
// ============================================================================
/// Jumps to a specific chapter and position.
- (void)gotoChapterIdx:(NSUInteger)chapterIdx
position:(NSUInteger)position
positionOfFile:(NSUInteger)positionOfFile {
NSLog(@"[WRReader] gotoChapterIdx:%lu position:%lu posFile:%lu",
(unsigned long)chapterIdx, (unsigned long)position,
(unsigned long)positionOfFile);
self.readingProgress.chapterIndex = chapterIdx;
self.readingProgress.pageIndex = position;
self.readingProgress.charIndex = positionOfFile;
// If the chapter is already cached, display it directly.
WRChapterData *cachedData = self.chapterDataCache[@(chapterIdx)];
if (cachedData) {
self.currentChapterData = cachedData;
[self _displayChapter:cachedData atPageIndex:position animated:YES];
return;
}
// Otherwise, load asynchronously.
[self _loadChapterAtIndex:chapterIdx
completion:^(WRChapterData *chapterData, NSError *error) {
if (chapterData) {
if (positionOfFile > 0) {
WRChapterPageCount *pageCount = [self _pageCountForChapterData:chapterData];
NSUInteger pageIdx = [pageCount pageIndexForCharacterIndex:positionOfFile];
self.readingProgress.pageIndex = pageIdx;
}
[self _displayChapter:chapterData
atPageIndex:self.readingProgress.pageIndex
animated:YES];
} else {
[self _showChapterLoadError:error];
}
}];
}
/// Advances to the next chapter.
- (void)jumpReadingToNextChapter {
NSUInteger nextIdx = self.readingProgress.chapterIndex + 1;
if (nextIdx >= self.totalChapters) return;
[self gotoChapterIdx:nextIdx position:0 positionOfFile:0];
}
/// Goes back to the previous chapter.
- (void)jumpReadingToPreChapter {
if (self.readingProgress.chapterIndex == 0) return;
NSUInteger prevIdx = self.readingProgress.chapterIndex - 1;
[self gotoChapterIdx:prevIdx position:0 positionOfFile:0];
}
// ============================================================================
#pragma mark - Page Flip Callback
// ============================================================================
/// Called by WRPageViewController when a page flip animation completes.
- (void)didFlipPage {
// Step 1: Update reading progress.
NSUInteger newPageIndex = self.pageViewController.currentPageIndex;
self.readingProgress.pageIndex = newPageIndex;
WRChapterPageCount *pageCount = self.currentChapterPageCount;
if (pageCount) {
NSRange pageRange = [pageCount rangeForPageAtIndex:newPageIndex];
if (pageRange.location != NSNotFound) {
self.readingProgress.charIndex = pageRange.location;
}
}
// Step 2: Calculate read percentage.
[self _updateReadPercentage];
// Step 3: Save progress (async).
[self _saveReadingProgressAndIsAsync:YES];
// Step 4: Notify delegate.
if ([self.readerDelegate respondsToSelector:
@selector(readerViewController:didUpdateProgress:)]) {
[self.readerDelegate readerViewController:self
didUpdateProgress:self.readingProgress];
}
// Step 5: Prefetch adjacent chapters.
[self _prefetchAdjacentChaptersForIndex:self.readingProgress.chapterIndex];
// Step 6: Post notification.
[[NSNotificationCenter defaultCenter]
postNotificationName:kReaderDidFlipPage
object:self
userInfo:@{@"progress": self.readingProgress}];
}
// ============================================================================
#pragma mark - Progress Persistence
// ============================================================================
/// Saves the reading progress to NSUserDefaults.
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync {
WRReadingProgress *progress = self.readingProgress;
if (!progress || !progress.bookId) return;
NSString *key = [kReadingProgressKeyPrefix stringByAppendingString:progress.bookId];
NSDictionary *dict = @{
@"bookId": progress.bookId ?: @"",
@"chapterIndex": @(progress.chapterIndex),
@"pageIndex": @(progress.pageIndex),
@"charIndex": @(progress.charIndex),
@"scrollOffset": @(progress.scrollOffset),
@"chapterId": progress.chapterId ?: @"",
@"readPercentage": @(progress.readPercentage),
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
};
if (isAsync) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
[[NSUserDefaults standardUserDefaults] synchronize];
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter]
postNotificationName:kReaderProgressSaved object:self];
});
});
} else {
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
[[NSUserDefaults standardUserDefaults] synchronize];
}
}
/// Loads the saved progress from NSUserDefaults.
- (WRReadingProgress * __nullable)_loadSavedProgress {
NSString *key = [kReadingProgressKeyPrefix stringByAppendingString:self.bookId ?: @""];
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:key];
if (!dict) return nil;
WRReadingProgress *progress = [[WRReadingProgress alloc] init];
progress.bookId = dict[@"bookId"];
progress.chapterIndex = [dict[@"chapterIndex"] unsignedIntegerValue];
progress.pageIndex = [dict[@"pageIndex"] unsignedIntegerValue];
progress.charIndex = [dict[@"charIndex"] unsignedIntegerValue];
progress.scrollOffset = [dict[@"scrollOffset"] doubleValue];
progress.chapterId = dict[@"chapterId"];
progress.readPercentage = [dict[@"readPercentage"] doubleValue];
return progress;
}
// ============================================================================
#pragma mark - Typesetter
// ============================================================================
/// Modifies typesetter attributes and triggers re-typeset.
- (void)changeTypesetterAttributesWithBlock:(void (^ __nonnull)(NSMutableDictionary *attrs))block {
NSMutableDictionary *attrs = [self _currentTypesetterAttributes].mutableCopy;
block(attrs);
[self _saveTypesetterAttributes:attrs];
[self.pageCountCache removeAllObjects];
WRCoreTextLayouter *layouter = self.currentChapterData.layouter;
[layouter updateAttributes:attrs];
[self recomposeCurrentPageViewWithSource:@"typesetterChange"];
}
/// Reads the current typesetter attributes from UserDefaults.
- (NSDictionary *)_currentTypesetterAttributes {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
return @{
@"fontSize": @([defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0),
@"lineSpacing": @([defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5),
@"fontFamily": [defaults stringForKey:@"WRTypesetterFontFamily"] ?: @"PingFang SC",
@"paragraphSpacing": @([defaults floatForKey:@"WRTypesetterParaSpacing"] ?: 8.0),
};
}
/// Saves typesetter attributes to UserDefaults.
- (void)_saveTypesetterAttributes:(NSDictionary *)attrs {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setFloat:[attrs[@"fontSize"] floatValue] forKey:@"WRTypesetterFontSize"];
[defaults setFloat:[attrs[@"lineSpacing"] floatValue] forKey:@"WRTypesetterLineSpacing"];
[defaults setObject:attrs[@"fontFamily"] forKey:@"WRTypesetterFontFamily"];
[defaults setFloat:[attrs[@"paragraphSpacing"] floatValue] forKey:@"WRTypesetterParaSpacing"];
[defaults synchronize];
}
// ============================================================================
#pragma mark - Pagination
// ============================================================================
/// Initializes or re-initializes the chapter page count calculator.
- (void)initChapterPageCount {
WRChapterData *chapterData = self.currentChapterData;
if (!chapterData) return;
// Generate the cache key based on book ID and current typesetter settings.
NSString *cacheKey = [WRChapterPageCount currentCacheKeyWithBookId:self.bookId];
// Check cache first.
WRChapterPageCount *cached = self.pageCountCache[cacheKey];
if (cached) {
self.currentChapterPageCount = cached;
return;
}
// Create a new page count calculator.
WRChapterPageCount *pageCount = [[WRChapterPageCount alloc] init];
pageCount.bookId = self.bookId;
pageCount.chapterId = chapterData.chapterId;
// Compute page ranges by simulating CoreText typesetting.
[pageCount recalculatePageRangesForAttributedString:chapterData.typesetAttributedString
drawingSize:self.view.bounds.size
margins:chapterData.contentInsets];
// Cache the result.
self.pageCountCache[cacheKey] = pageCount;
self.currentChapterPageCount = pageCount;
}
// ============================================================================
#pragma mark - Internal Chapter Loading
// ============================================================================
/// Loads a chapter by index, with caching and retry logic.
- (void)_loadChapterAtIndex:(NSUInteger)index
completion:(void (^)(WRChapterData *, NSError *))completion {
// Check cache first.
WRChapterData *cached = self.chapterDataCache[@(index)];
if (cached) {
if (completion) completion(cached, nil);
return;
}
// Prevent duplicate loads.
if (self.isLoadingChapter) return;
self.isLoadingChapter = YES;
__weak typeof(self) weakSelf = self;
dispatch_async(self.chapterLoadQueue, ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
// In the real implementation, this:
// 1. Fetches the chapter HTML/content from the server or local cache.
// 2. Parses the HTML into an NSAttributedString.
// 3. Runs WRCoreTextLayouter to typeset the chapter.
// 4. Stores the result in the cache.
// For this reconstruction, we simulate the flow:
NSError *error = nil;
WRChapterData *chapterData = [strongSelf _fetchChapterDataForIndex:index
error:&error];
dispatch_async(dispatch_get_main_queue(), ^{
strongSelf.isLoadingChapter = NO;
if (chapterData) {
strongSelf.chapterDataCache[@(index)] = chapterData;
strongSelf.loadRetryCount = 0;
if (completion) completion(chapterData, nil);
} else {
// Retry logic.
if (strongSelf.loadRetryCount < strongSelf.maxRetryCount) {
strongSelf.loadRetryCount++;
[strongSelf _loadChapterAtIndex:index completion:completion];
} else {
strongSelf.loadRetryCount = 0;
if (completion) completion(nil, error);
}
}
});
});
}
/// Fetches and typesets a chapter (placeholder for the real network/cache logic).
- (WRChapterData * __nullable)_fetchChapterDataForIndex:(NSUInteger)index
error:(NSError **)errorOut {
// In the real app, this method:
// 1. Checks local SQLite/LevelDB cache for the chapter content.
// 2. If not cached, makes an API request to the WeRead server.
// 3. Receives chapter HTML (potentially encrypted/obfuscated).
// 4. Decrypts and parses the HTML into an NSAttributedString.
// 5. Creates a WRCoreTextLayouter with the current typesetter attributes.
// 6. Runs the layouter to compute line breaks and page breaks.
// 7. Returns the populated WRChapterData.
// Placeholder: return nil to simulate a network fetch that needs to happen
// in the real binary.
if (errorOut) {
*errorOut = [NSError errorWithDomain:@"com.weread.reader"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Not implemented in reconstruction"}];
}
return nil;
}
/// Returns the chapter data for the given index (from cache or nil).
- (WRChapterData * __nullable)_chapterDataForIndex:(NSUInteger)index {
return self.chapterDataCache[@(index)];
}
/// Returns the page count for the given chapter data, computing it if needed.
- (WRChapterPageCount * __nullable)_pageCountForChapterData:(WRChapterData *)chapterData {
if (!chapterData) return nil;
NSString *cacheKey = [WRChapterPageCount currentCacheKeyWithBookId:self.bookId];
WRChapterPageCount *cached = self.pageCountCache[cacheKey];
if (cached) return cached;
// Compute now.
[self initChapterPageCount];
return self.currentChapterPageCount;
}
/// Re-typesets a chapter data object (after font/spacing changes).
- (void)_reTypesetChapterData:(WRChapterData *)chapterData {
if (!chapterData.sourceAttributedString) return;
// Re-create the layouter with updated attributes.
NSDictionary *attrs = [self _currentTypesetterAttributes];
WRCoreTextLayouter *layouter = [[WRCoreTextLayouter alloc]
initWithAttributedString:chapterData.sourceAttributedString
attributes:attrs];
chapterData.layouter = layouter;
// Re-generate the typeset attributed string.
chapterData.typesetAttributedString = [layouter typesetAttributedString];
// Re-generate page ranges.
[self initChapterPageCount];
}
// ============================================================================
#pragma mark - Chapter Prefetching
// ============================================================================
/// Prefetches the next and previous chapters so they're ready when the
/// user flips to them.
- (void)_prefetchAdjacentChaptersForIndex:(NSUInteger)index {
// Prefetch next chapter.
if (index + 1 < self.totalChapters) {
NSUInteger nextIdx = index + 1;
if (!self.chapterDataCache[@(nextIdx)]) {
dispatch_async(self.chapterLoadQueue, ^{
[self _fetchChapterDataForIndex:nextIdx error:NULL];
});
}
}
// Prefetch previous chapter.
if (index > 0) {
NSUInteger prevIdx = index - 1;
if (!self.chapterDataCache[@(prevIdx)]) {
dispatch_async(self.chapterLoadQueue, ^{
[self _fetchChapterDataForIndex:prevIdx error:NULL];
});
}
}
}
// ============================================================================
#pragma mark - Display Helpers
// ============================================================================
/// Displays a chapter at the given page index.
- (void)_displayChapter:(WRChapterData *)chapterData
atPageIndex:(NSUInteger)pageIndex
animated:(BOOL)animated {
self.currentChapterData = chapterData;
[self initChapterPageCount];
// Create a page view for the initial display.
WRPageView *pageView = [[WRPageView alloc] initWithFrame:self.view.bounds];
[self.activePageViews removeAllObjects];
[self.activePageViews addObject:pageView];
// Render the page.
WRReadingProgress *progress = [self.readingProgress copy];
progress.pageIndex = pageIndex;
[self renderPageView:pageView progressData:progress source:@"displayChapter"];
// Set the page view controller's initial view controller.
UIViewController *pageContentVC = [[UIViewController alloc] init];
pageContentVC.view = pageView;
[self.pageViewController setViewControllers:@[pageContentVC]
direction:UIPageViewControllerNavigationDirectionForward
animated:animated
completion:nil];
}
/// Shows an error state when chapter loading fails.
- (void)_showChapterLoadError:(NSError *)error {
NSLog(@"[WRReader] Chapter load error: %@", error.localizedDescription);
// In the real app, this shows a toast or error overlay.
}
/// Calculates and updates the overall read percentage.
- (void)_updateReadPercentage {
NSUInteger totalChapters = self.totalChapters;
if (totalChapters == 0) return;
NSUInteger currentChapter = self.readingProgress.chapterIndex;
WRChapterPageCount *pageCount = self.currentChapterPageCount;
NSUInteger totalPages = pageCount.totalPages;
NSUInteger currentPage = self.readingProgress.pageIndex;
// Calculate: (chaptersCompleted + currentPage/totalPages) / totalChapters
double chapterProgress = (totalPages > 0)
? (double)currentPage / (double)totalPages
: 0.0;
double overall = ((double)currentChapter + chapterProgress) / (double)totalChapters;
self.readingProgress.readPercentage = MIN(MAX(overall, 0.0), 1.0);
}
// ============================================================================
#pragma mark - Auto-Read
// ============================================================================
/// Starts the auto-read timer.
- (void)_startAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer =
[NSTimer scheduledTimerWithTimeInterval:5.0
target:self
selector:@selector(_autoReadTick)
userInfo:nil
repeats:YES];
}
/// Stops auto-read.
- (void)_stopAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer = nil;
}
/// Called each auto-read tick to advance the page.
- (void)_autoReadTick {
BOOL advanced = [self.pageViewController goToNextPageAnimated:YES];
if (!advanced) {
// At the end of the chapter, try to go to the next chapter.
if (self.readingProgress.chapterIndex + 1 < self.totalChapters) {
[self jumpReadingToNextChapter];
} else {
// At the end of the book, stop auto-read.
[self _stopAutoRead];
}
}
}
// ============================================================================
#pragma mark - Notification Handlers
// ============================================================================
/// Handles memory warning by purging non-current chapter data from cache.
- (void)_handleMemoryWarning:(NSNotification *)note {
NSLog(@"[WRReader] Memory warning received, purging chapter cache.");
NSUInteger currentIdx = self.readingProgress.chapterIndex;
WRChapterData *currentData = self.chapterDataCache[@(currentIdx)];
[self.chapterDataCache removeAllObjects];
if (currentData) {
self.chapterDataCache[@(currentIdx)] = currentData;
}
[self.pageCountCache removeAllObjects];
}
/// Handles the auto-read advance notification from WRPageView.
- (void)_handleAutoReadAdvance:(NSNotification *)note {
if (!_autoReadEnabled) return;
[self _autoReadTick];
}
/// Handles font size change notification from WRPageView.
- (void)_handleFontSizeChange:(NSNotification *)note {
NSInteger delta = [note.userInfo[@"delta"] integerValue];
if (delta == 0) return;
[self changeTypesetterAttributesWithBlock:^(NSMutableDictionary *attrs) {
CGFloat currentSize = [attrs[@"fontSize"] floatValue];
CGFloat newSize = currentSize + (CGFloat)delta;
newSize = MAX(12.0, MIN(36.0, newSize)); // Clamp to reasonable range.
attrs[@"fontSize"] = @(newSize);
}];
}
// ============================================================================
#pragma mark - Book Data Loading
// ============================================================================
/// Loads book metadata from cache or server.
- (void)_loadBookDataWithBookId:(NSString *)bookId {
// In the real app, this makes an API call to fetch book metadata:
// - Title, author, cover image URL
// - Chapter list (IDs, titles)
// - User's reading progress (if synced)
// - Trial/free chapter limits
//
// The response populates self.book and self.readingProgress.
}
// ============================================================================
#pragma mark - User Preferences
// ============================================================================
/// Returns the user's preferred page flipping style from UserDefaults.
- (WRPageFlippingStyle)_userPreferredFlipStyle {
NSInteger style = [[NSUserDefaults standardUserDefaults]
integerForKey:@"WRReaderFlipStyle"];
return (WRPageFlippingStyle)style;
}
/// Periodic progress save callback.
- (void)_periodicProgressSave {
[self _saveReadingProgressAndIsAsync:YES];
}
// ============================================================================
#pragma mark - WRPageViewControllerDelegate
// ============================================================================
/// Called when the page view controller finishes a page transition.
- (void)pageViewController:(WRPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed {
if (completed) {
[self didFlipPage];
}
}
/// Called before a page transition begins.
- (void)pageViewController:(WRPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
// Pre-render the upcoming page view.
if (pendingViewControllers.count > 0) {
// The pending VC's page view needs its layout frame set.
// This happens in renderPageView: when the transition completes.
}
}
// ============================================================================
#pragma mark - Total Chapters (computed)
// ============================================================================
/// Returns the total number of chapters in the book.
- (NSUInteger)totalChapters {
// In the real app, this comes from the book model.
return self.book.chapterCount ?: 0;
}
@end