Epub阅读器0.0.1
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user