普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月6日首页

iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

作者 iceiceiceice
2026年3月5日 18:13

iOS PDF 阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

目标读者:有 PDFKit 使用经验的 iOS 开发者。
本文重点:几何分块算法、段落识别逻辑、跨栏语义合并三个核心难点。


背景:段评是什么,难在哪里

杂志类 App 有一个常见需求——用户长按某段正文,划出一段话,然后对这段话写评论。这个交互在微信读书、Kindle 里都很成熟,但它们针对的是结构化的电子书格式(ePub、MOBI),正文结构天然清晰。

PDF 没有这种结构。一份杂志 PDF 在底层只有一堆带坐标的"文字片段"(glyph run),没有段落、没有栏、没有语义层次。PDFKit 提供的 PDFSelectionselectionsByLine 能给你"行",但它不知道哪些行属于同一个段落,也不知道这一页有几栏。

因此,段评的核心问题是:给定用户选中的一行文字,如何还原它所在的完整自然段?

这个问题比想象中复杂,主要难点有三个:

  1. 几何噪声:PDF 的行坐标存在浮点误差,标题、页码、图注混杂其中,必须过滤。
  2. 多栏布局:杂志常见双栏、三栏排版,阅读顺序不是简单地从上到下。
  3. 跨栏断段:一个自然段可能从左栏末尾延续到右栏开头,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 模型,携带 textrectpagepageIndex 等属性,供后续层使用。


第二层:几何连通分块

拿到干净的行列表后,下一个问题是:这一页上有几个独立的文字区域?

杂志版式复杂,一页上可能同时存在主正文区、侧边栏、图注、引言框等多个互不相连的文字区域。如果不先区分这些区域,段落识别就会跨区混淆。

引擎使用了一个经典的**几何连通图(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:&paragraphIndex];
        [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 的设计

完成以上步骤后,每一行都携带了 pageIndexblockIndexparagraphIndex 三个坐标。段落 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 均有较好的适应性。

❌
❌