iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段
iOS PDF 阅读器段评实现:如何从 PDFSelection 精准还原一个自然段
目标读者:有 PDFKit 使用经验的 iOS 开发者。
本文重点:几何分块算法、段落识别逻辑、跨栏语义合并三个核心难点。
背景:段评是什么,难在哪里
杂志类 App 有一个常见需求——用户长按某段正文,划出一段话,然后对这段话写评论。这个交互在微信读书、Kindle 里都很成熟,但它们针对的是结构化的电子书格式(ePub、MOBI),正文结构天然清晰。
PDF 没有这种结构。一份杂志 PDF 在底层只有一堆带坐标的"文字片段"(glyph run),没有段落、没有栏、没有语义层次。PDFKit 提供的 PDFSelection 和 selectionsByLine 能给你"行",但它不知道哪些行属于同一个段落,也不知道这一页有几栏。
因此,段评的核心问题是:给定用户选中的一行文字,如何还原它所在的完整自然段?
这个问题比想象中复杂,主要难点有三个:
- 几何噪声:PDF 的行坐标存在浮点误差,标题、页码、图注混杂其中,必须过滤。
- 多栏布局:杂志常见双栏、三栏排版,阅读顺序不是简单地从上到下。
- 跨栏断段:一个自然段可能从左栏末尾延续到右栏开头,PDFKit 对此一无所知。
XLPDFParagraphEngine 的设计思路,就是用纯几何方法逐层解决这三个问题。
整体架构:四层流水线
整个引擎的入口是:
/// 自定义PDFView里面获取menu
+ (NSString *)paragraphTextFromSelection:(PDFSelection *)selection
document:(PDFDocument *)document;
它的内部执行路径是一条清晰的四层流水线:
PDFSelection
│
▼
① buildLinesFromSelection — 行提取 + 噪声过滤
│
▼
② buildBlocksFromLinesIteratively — 几何连通分块
│
▼
③ readingOrderForBlock — 列识别 + 段落切分
│
▼
④ mergeSemanticContinuousBlocks — 跨栏语义合并
│
▼
paragraphTextFromLines — 拼接文本输出
每一层解决一个独立问题,下面逐层展开。
第一层:行提取与噪声过滤
PDFKit 的 selectionsByLine 会把选区内的每一行作为独立的 PDFSelection 返回,这是我们的原始数据源。但原始数据有大量噪声需要清理。
/// 获取所有lines
+ (NSArray<XLPDFLine *> *)buildLinesFromPage:(PDFPage *)page document:(PDFDocument *)document {
CGRect pageRect = [page boundsForBox:kPDFDisplayBoxMediaBox];
PDFSelection *pageSelection = [page selectionForRect:pageRect];
return [self buildLinesFromBaseSelection:pageSelection document:document];
}
/// 获取选中的lines
+ (NSArray<XLPDFLine *> *)buildLinesFromSelection:(PDFSelection *)selection document:(PDFDocument *)document {
return [self buildLinesFromBaseSelection:selection document:document];
}
+ (NSArray<XLPDFLine *> *)buildLinesFromBaseSelection:(PDFSelection *)baseSelection document:(PDFDocument *)document {
NSMutableArray<XLPDFLine *> *lines = [NSMutableArray array];
NSArray<PDFPage *> *pages = baseSelection.pages;
for (PDFSelection sel in baseSelection.selectionsByLine) {
NSString *text = sel.string;
if (text.length == 0) continue;
// 找到当前行所属 page
PDFPage *linePage = nil;
CGRect rect = CGRectZero;
for (PDFPage *page in pages) {
rect = [sel boundsForPage:page];
if (!CGRectIsEmpty(rect)) {
linePage = page;
break;
}
}
if (!linePage) continue;
if (CGRectIsEmpty(rect)) continue;
// ========= 公共过滤逻辑 =========
CGFloat width = CGRectGetWidth(rect);
CGFloat height = CGRectGetHeight(rect);
// 过滤竖排
if (text.length > 1 && height > width * 2.0) continue;
// 过滤异常高度
CGRect pageRect = [linePage boundsForBox:kPDFDisplayBoxMediaBox];
CGFloat pageHeight = CGRectGetHeight(pageRect);
if (height > pageHeight * 0.05) continue;
NSString *trimText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (trimText.length == 0) continue;
// 过滤纯数字编号(01、02、1、2、一、二 等页码/序号)
NSString *numberPattern = @"^\\s*[零一二三四五六七八九十百\\d]+[、.]?\\s*$";
NSPredicate *numberPredicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", numberPattern];
if ([numberPredicate evaluateWithObject:trimText]) continue;
// ========= 构建模型 =========
XLPDFLine *line = [XLPDFLine new];
line.selection = sel;
line.page = linePage;
line.rect = rect;
line.text = trimText;
NSInteger pageIndex = [document indexForPage:linePage];
line.pageIndex = pageIndex == NSNotFound ? -1 : pageIndex;
[lines addObject:line];
}
return lines;
}
这里的过滤策略针对杂志 PDF 的典型噪声:
-
竖排文字:部分杂志有竖向装饰文字,
height > width * 2.0可以有效识别并排除。 - 异常高度:正文行高通常不超过页面高度的 5%,超出这个比例的往往是大图、横幅或装饰元素。
-
页码与序号:正则
^[零一二三四五六七八九十百\d]+[、.]?$可以匹配中英文页码和列表编号,避免它们干扰后续分段判断。
过滤完成后,每一行被封装成 XLPDFLine 模型,携带 text、rect、page、pageIndex 等属性,供后续层使用。
第二层:几何连通分块
拿到干净的行列表后,下一个问题是:这一页上有几个独立的文字区域?
杂志版式复杂,一页上可能同时存在主正文区、侧边栏、图注、引言框等多个互不相连的文字区域。如果不先区分这些区域,段落识别就会跨区混淆。
引擎使用了一个经典的**几何连通图(Connected Components)**算法:
将每一行的 rect 向外膨胀(inflate)半个行高
如果两行膨胀后的 rect 有交叉 → 认为它们"连通"
对所有行做图的连通分量遍历 → 每个连通分量就是一个 Block
膨胀量选择行高的 50%,是一个关键的经验值设定:
+ (BOOL)linesConnected:(XLPDFLine *)a other:(XLPDFLine *)b {
CGFloat insetA = a.rect.size.height * 0.5;
CGFloat insetB = b.rect.size.height * 0.5;
CGRect ra = CGRectInset(a.rect, -insetA, -insetA);
CGRect rb = CGRectInset(b.rect, -insetB, -insetB);
return CGRectIntersectsRect(ra, rb);
}
为什么不用固定像素值?因为杂志里的字号差异很大——正文可能是 10pt,大标题可能是 36pt。固定像素膨胀会导致小字号的脚注与正文粘连,或者大标题与相邻栏文字误连。用行高比例膨胀,让每行的"感知范围"与自身字号成正比,鲁棒性更好。
连通分量的遍历使用迭代 DFS:
+ (NSArray *)buildBlocksFromLinesIteratively:(NSArray *)lines {
NSMutableArray *remaining = [lines mutableCopy];
NSMutableArray *resultBlocks = [NSMutableArray array];
while (remaining.count > 0) {
NSArray *block = [self buildSingleBlockFromLines:remaining];
[resultBlocks addObject:block];
[remaining removeObjectsInArray:block];
}
return resultBlocks;
}
每次从剩余行中任取一行作为起点,BFS/DFS 扩展出整个连通分量,然后从剩余集合中移除,直到所有行都被分配完毕。
第三层:阅读顺序还原与段落切分
每个 Block 内部可能还有多列(例如一个双栏正文区,在几何上是一个连通分量)。这一层先识别列,再在每列内部切分段落。
3.1 列识别:X 轴区间合并
+ (NSArray<XLPDFLine *> *)readingOrderForBlock:(NSArray<XLPDFLine *> *)block {
NSArray *ranges = [self xRangesFromBlock:block]; // 投影到X轴:x+w
NSArray *columnRanges = [self mergeXRanges:ranges]; // 算出有多少列
NSArray *columns = [self splitBlock:block intoColumns:columnRanges]; // 划入列里
NSMutableArray *result = [NSMutableArray array];
NSInteger paragraphIndex = 0;
// 列里面直接按照Y排序即可
for (NSArray<XLPDFLine *> *column in columns) {
// 分段
NSArray *ordered = [self readingOrderForColumnByIndentOnly:column paragraphStartIndex:¶graphIndex];
[result addObjectsFromArray:ordered];
}
return result;
}
具体做法:把 Block 内每一行的 [minX, maxX] 区间收集起来,按 minX 排序后做区间合并(sweep line),相互重叠或相接的区间合并为一个列边界。最终得到若干互不重叠的列区间,每一行按其中心 X 坐标归入对应的列。
这个方法的优势是完全不依赖任何先验知识,无论一页有几栏、栏宽是否均等,都能正确识别。
3.2 段落切分:三条几何规则
列识别完成后,列内的行按 Y 坐标从上到下排好序。接下来要判断相邻两行是否属于同一段落,引擎使用了三条互补的规则:
+ (NSArray<XLPDFLine *> *)readingOrderForColumnByIndentOnly:(NSArray<XLPDFLine *> *)column paragraphStartIndex:(NSInteger *)paragraphIndex {
NSArray<XLPDFLine *> *sorted = [self sortLinesByYDescending:column];
CGFloat baseMinX = [self baseMinXForColumn:sorted];
CGFloat baseMaxX = [self baseMaxXForColumn:sorted];
CGFloat columnWidth = baseMaxX - baseMinX;
[sorted enumerateObjectsUsingBlock:^(XLPDFLine * _Nonnull line, NSUInteger idx, BOOL * _Nonnull stop) {
if (idx > 0) {
XLPDFLine *prevLine = sorted[idx - 1];
// 条件1:当前行首行缩进
CGFloat indent = CGRectGetMinX(line.rect) - baseMinX;
BOOL currentLineIsHead = indent > 10.0;
// 条件2:上一行是尾行(右侧留白超过列宽 10%)
CGFloat prevLineTrailingGap = baseMaxX - CGRectGetMaxX(prevLine.rect);
BOOL prevLineIsTail = prevLineTrailingGap > columnWidth * 0.1;
// 条件3:行间距超过行高阈值
CGFloat gap = CGRectGetMinY(prevLine.rect) - CGRectGetMaxY(line.rect);
CGFloat lineHeight = CGRectGetHeight(line.rect);
BOOL hasLargeGap = gap > lineHeight * 0.8;
if (currentLineIsHead || prevLineIsTail || hasLargeGap) {
(*paragraphIndex)++;
}
}
line.paragraphIndex = *paragraphIndex;
}];
return sorted;
}
规则1(首行缩进) 是中文排版最常见的段落标记,10pt 的阈值约等于一个汉字的宽度。
规则2(末行留白) 是规则1的补充:段落末行通常不会写满整行。15% 的阈值过滤掉因行尾标点导致的微小留白,同时能识别出明显的短尾行。注意这里使用的 baseMaxX 是列内所有行 maxX 的中位数,而不是最大值,这样对行尾有标点突出的情况更鲁棒。
规则3(行间距) 用于处理无缩进、无留白但通过空行分隔的段落风格(英文排版常见)。
三条规则取 OR,任意一条满足就认为新段落开始,paragraphIndex 递增。
第四层:跨栏语义合并(最难的部分)
前三层解决了单个 Block 内部的问题,但杂志双栏排版有一个特殊情况:
一个自然段从左栏末尾开始,写满后在右栏顶部继续。
这两部分在几何上属于不同的 Block(左栏和右栏不连通),但语义上是同一个段落。这就是跨栏语义合并问题。
4.1 判断标准:段尾 + 段首
合并的充要条件是:左栏某 Block 的最后一行是段尾行(Tail) ,同时右栏某 Block 的第一行是段首行(Head) ,且两者字号一致。
段尾判断:末行写满(右侧留白 < 列宽10%)且没有句末标点(。!?;…等):
+ (BOOL)isTailBlock:(NSArray *)block {
XLPDFLine *lastLine = block.lastObject;
CGFloat trailingGap = columnMaxX - CGRectGetMaxX(lastLine.rect);
BOOL noTrailingGap = trailingGap <= columnWidth * 0.1;
BOOL noEndingSymbol = ![self lineEndsWithParagraphSymbol:lastLine];
return noTrailingGap && noEndingSymbol;
}
段尾判断:判断一行是否以句末标点结尾(处理引号包裹和英文小数)
+ (BOOL)lineEndsWithParagraphSymbol:(XLPDFLine *)line {
NSCharacterSet *endingSymbols = [NSCharacterSet characterSetWithCharactersInString:@"。!?;.!?;…"];
NSCharacterSet *wrapperSet =
[NSCharacterSet characterSetWithCharactersInString:@"”’\"'))】》〉 "];
NSString *trimmed = [line.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (trimmed.length == 0) return NO;
NSInteger index = (NSInteger)trimmed.length - 1;
// 跳过包裹字符
while (index >= 0 &&
[wrapperSet characterIsMember:[trimmed characterAtIndex:index]]) {
index--;
}
if (index < 0) return NO;
unichar c = [trimmed characterAtIndex:index];
// 英文小数点不算句末(3.14)
if (c == '.' && index > 0 &&
[[NSCharacterSet decimalDigitCharacterSet]
characterIsMember:[trimmed characterAtIndex:index - 1]]) {
return NO;
}
return [endingSymbols characterIsMember:c];
}
段首判断:首行无明显缩进(缩进量 ≤ 10pt),说明这一行是接续上一栏的内容,而不是新段落的起点:
+ (BOOL)isHeadBlock:(NSArray *)block {
XLPDFLine *firstLine = block.firstObject;
CGFloat indent = CGRectGetMinX(firstLine.rect) - columnMinX;
return indent <= 10.0;
}
解决多栏PDF的跨列语义连续问题:
/// 先对所有allPageLines进行分列(大概2、3列)
/// 将所有block归列,每一列N个block
/// 每一列的从后往前找可能是段尾的block
/// 从第二列内部blocks遍历从前往后判断是否是段首
/// 合并段位和段首,处理blockIndex、paragraphIndex
+ (NSArray<NSArray<XLPDFLine *> *> *)mergeSemanticContinuousBlocks:(NSArray<NSArray<XLPDFLine *> *> *)blocks pageLines:(NSArray<XLPDFLine *> *)allPageLines {
if (blocks.count < 2) return blocks;
NSArray<NSValue *> *columnRanges = [self detectColumnRanges:allPageLines];
if (columnRanges.count < 2) return blocks;
// 按列分组(保持列内Y排序)
NSMutableArray<NSMutableArray *> *columns = [NSMutableArray array];
for (NSInteger i = 0; i < columnRanges.count; i++) {
[columns addObject:[NSMutableArray array]];
}
for (NSArray<XLPDFLine *> *block in blocks) {
NSInteger colIdx = [self columnIndexForBlock:block inRanges:columnRanges];
if (colIdx >= 0) [columns[colIdx] addObject:[block mutableCopy]];
}
for (NSMutableArray *column in columns) {
[column sortUsingComparator:^NSComparisonResult(NSArray *a, NSArray *b) {
CGFloat maxYA = 0, maxYB = 0;
for (XLPDFLine *l in a) maxYA = MAX(maxYA, CGRectGetMaxY(l.rect));
for (XLPDFLine *l in b) maxYB = MAX(maxYB, CGRectGetMaxY(l.rect));
return maxYA > maxYB ? NSOrderedAscending : NSOrderedDescending;
}];
}
// 跨列合并
for (NSInteger col = 0; col < (NSInteger)columns.count - 1; col++) {
NSMutableArray *currentCol = columns[col];
NSMutableArray *nextCol = columns[col + 1];
if (currentCol.count == 0 || nextCol.count == 0) continue;
CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
NSMutableArray<XLPDFLine *> *tailBlock = nil;
for (NSInteger blockIdx = (NSInteger)currentCol.count - 1; blockIdx >= 0; blockIdx--) {
// 特别注意要倒叙,然后过滤飞主体文本block
NSMutableArray<XLPDFLine *> *block = currentCol[blockIdx];
if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
[self isTailBlock:block]) {
tailBlock = block;
break;
}
}
if (!tailBlock) continue;
NSInteger searchCol = col + 1;
while (searchCol < (NSInteger)columns.count) {
NSMutableArray *searchNextCol = columns[searchCol];
if (searchNextCol.count == 0) {
searchCol++;
continue;
}
NSArray<XLPDFLine *> *headBlock = nil;
NSInteger headIdx = -1;
for (NSInteger i = 0; i < (NSInteger)searchNextCol.count; i++) {
NSArray<XLPDFLine *> *block = searchNextCol[i];
if ([self isHeadBlock:block] &&
[self blockContainsParagraphEndingSymbol:block] &&
[self lineHeightMatches:tailBlock with:block]) {
headBlock = block;
headIdx = i;
break;
}
}
if (!headBlock) break;
[self mergeBlock:headBlock intoBlock:tailBlock];
[searchNextCol removeObjectAtIndex:headIdx];
if (![self isTailBlock:tailBlock]) break;
searchCol++;
}
}
// 重整blockIndex + 构建结果数组
NSMutableArray<NSArray<XLPDFLine *> *> *result = [NSMutableArray array];
NSInteger idx = 0;
for (NSMutableArray *column in columns) {
for (NSArray<XLPDFLine *> *block in column) {
for (XLPDFLine *line in block) line.blockIndex = idx;
idx++;
if ([self blockContainsParagraphEndingSymbol:block] || block.count > 6) {
[result addObject:block];
}
}
}
return [result copy];
}
4.2 列检测:中心 X 聚类
跨栏合并需要先知道页面有几列,以及每列的 X 边界。引擎用了一个轻量的聚类方法:
// 收集所有行的中心X,排序后按间隙聚类
// 相邻 centerX 差值超过页宽的 10% → 认为是列间距
CGFloat gapThreshold = CGRectGetWidth(pageRect) * 0.10;
通过这个间隙阈值,可以把所有行的中心 X 分成若干簇,每簇的 [minX, maxX] 加上半行高的 padding 就是列的 X 范围。这比依赖页面宽度平均分割更准确,因为杂志栏宽不一定均等。
4.3 合并过程:倒序扫描 + 链式追踪
for (NSInteger col = 0; col < columns.count - 1; col++) {
// 在当前列,倒序找最后一个"主体文字"的段尾 Block
// (倒序是为了跳过可能存在的图注、小标题等非主体 Block)
NSMutableArray *tailBlock = nil;
CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
for (NSInteger i = currentCol.count - 1; i >= 0; i--) {
NSArray *block = currentCol[i];
if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
[self isTailBlock:block]) {
tailBlock = block;
break;
}
}
if (!tailBlock) continue;
// 在下一列,找第一个满足条件的段首 Block
// 字号一致 + 无缩进 + 含句末标点(保证是正文段落,不是纯标题)
NSArray *headBlock = nil;
for (NSArray *block in nextCol) {
if ([self isHeadBlock:block] &&
[self blockContainsParagraphEndingSymbol:block] &&
[self lineHeightMatches:tailBlock with:block]) {
headBlock = block;
break;
}
}
// 合并:将 headBlock 的所有行追加进 tailBlock,修正 blockIndex 和 paragraphIndex
[self mergeBlock:headBlock intoBlock:tailBlock];
[nextCol removeObject:headBlock];
// 如果合并后 tailBlock 仍是段尾 → 继续追踪到下下列(三栏情况)
if ([self isTailBlock:tailBlock]) { /* 继续向右搜索 */ }
}
合并时对 paragraphIndex 的修正是一个容易出错的地方。next Block 的 paragraphIndex 从 0 开始编号,合并时需要续接 prev Block 的最大 paragraphIndex,同时修正 blockIndex 保持一致:
for (XLPDFLine *line in next) {
line.blockIndex = prevBlockIndex;
line.paragraphIndex = (line.paragraphIndex - nextBaseIndex) + maxParagraphIndex;
[prev addObject:line];
}
段落 ID 的设计
完成以上步骤后,每一行都携带了 pageIndex、blockIndex、paragraphIndex 三个坐标。段落 ID 由此生成:
mgid_pageIndex_blockIndex_paragraphIndex
例如:mag001_3_2_1 表示杂志 mag001,第 3 页,第 2 个文字区域,第 1 个段落。
这个 ID 有两个关键用途:
写入评论时:通过 paragraphIDFromSelection:document:mgid: 生成 ID,与评论数据一起存储到服务端。
读取评论时:通过 paragraphTextFromParagraphID:document: 反向解析 ID,定位到页面 → Block → paragraphIndex,取出对应行集合,用于高亮展示或文字复原。
反向定位的路径:
// 1. 解析 ID,得到 pageIndex / blockIndex / paragraphIndex
// 2. 取出对应页面
PDFPage *page = [document pageAtIndex:pageIndex];
// 3. 对整页重新执行分块
NSArray *pageBlocks = [self pageLinesBlocksFromPage:page document:document];
// 4. 按 blockIndex 取出对应 Block
NSArray *block = pageBlocks[blockIndex];
// 5. 按 paragraphIndex 过滤出段落行
NSArray *paragraph = [self paragraphLinesForParagraphIndex:paragraphIndex inBlock:block];
评论气泡(PDFAnnotation)的锚点应该定位在段落最后一行的位置,这样气泡显示在段尾更自然,同时把段尾行的位置信息传给服务器,服务端也能精确还原气泡坐标。
几个值得关注的工程细节
同行判断的阈值:PDF 中同一行的不同字符因字体 baseline 差异,midY 可能相差 1~3pt。引擎用行高的 50% 作为阈值,而不是固定的 1pt,避免同行字符被误判为不同行:
+ (BOOL)isSameLineByY:(CGRect)r1 rect:(CGRect)r2 {
CGFloat threshold = MIN(CGRectGetHeight(r1), CGRectGetHeight(r2)) * 0.5;
return fabs(CGRectGetMidY(r1) - CGRectGetMidY(r2)) < threshold;
}
列 maxX 用中位数:baseMaxXForColumn: 返回的是所有行 maxX 的中位数,而不是最大值。这样可以过滤掉个别行尾有标点符号溢出导致的 maxX 偏大问题,让"末行留白"的判断更稳定。
主体行高过滤:在跨栏合并中,用 dominantLineHeightInColumn: 计算列内出现频率最高的行高(取整后做频次统计),作为主体正文的行高基准。倒序扫描段尾 Block 时,只考虑字号与主体行高接近的 Block,这样可以跳过可能夹在正文之间的小字号图注或大字号小标题。
局限性与未来方向
当前实现在以下场景有一定局限:
- 竖排中文:过滤规则直接丢弃竖排行,不支持竖排杂志。
- 不规则分栏:栏宽差异极大时(如 1:3 的图文混排),X 轴聚类可能误判列数。
- 跨页段落:目前只处理单页内的跨栏,跨页的段落连续暂不支持。
- 表格内文字:表格单元格中的文字可能因行高相近而被当作正文处理。
小结
XLPDFParagraphEngine 的核心设计思路可以归纳为:用几何信息替代语义信息,逐层收敛不确定性。
| 层次 | 输入 | 输出 | 解决的问题 |
|---|---|---|---|
| 行提取 | PDFSelection | XLPDFLine 数组 | 去除噪声行 |
| 几何分块 | XLPDFLine 数组 | Block 数组 | 区分独立文字区域 |
| 列识别 + 分段 | Block | 带 paragraphIndex 的行 | 还原阅读顺序和段落边界 |
| 跨栏合并 | Block 数组 | 合并后的 Block 数组 | 修复跨栏断段 |
整个流水线不依赖任何 PDF 元数据,仅凭坐标和文本表面特征运作,因此对不同来源、不同排版风格的杂志 PDF 均有较好的适应性。