You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

能否像Google Maps一样对PDFtron Flutter中的PDF批注进行聚类?

实现类似Google Maps的PDF批注聚类功能(基于Flutter Pdftron)

核心思路

借助Pdftron的批注API获取所有批注的位置信息,结合当前PDF视图的缩放比例、滚动偏移完成PDF坐标到屏幕坐标的转换,通过密度聚类算法将邻近批注分组,在缩小时渲染聚类标记(显示组内批注数量),点击标记时自动放大并展开组内所有批注,解决缩小后批注重叠无法操作的问题。

具体实现步骤

1. 获取批注及坐标转换

通过Pdftron的AnnotationManager提取所有批注的边界框,转换为屏幕坐标系下的位置:

// 获取当前PDF所有批注
List<Annot> annotations = await PdftronFlutter.getAnnotations();
// 存储转换后的批注数据
List<Map<String, dynamic>> annotScreenData = [];

// 获取当前视图状态
double currentZoom = await PdftronFlutter.getZoom();
Offset scrollOffset = await PdftronFlutter.getScrollOffset();

for (var annot in annotations) {
  Rect pdfRect = annot.boundingRect;
  // 将PDF坐标转换为屏幕坐标
  Rect screenRect = Rect.fromLTWH(
    (pdfRect.left - scrollOffset.dx) * currentZoom,
    (pdfRect.top - scrollOffset.dy) * currentZoom,
    pdfRect.width * currentZoom,
    pdfRect.height * currentZoom,
  );
  annotScreenData.add({
    'annot': annot,
    'screenCenter': Offset(screenRect.center.dx, screenRect.center.dy),
    'screenRect': screenRect,
  });
}

2. 实现密度聚类算法

采用DBSCAN算法(适合空间密度聚类),根据屏幕距离阈值将邻近批注分组:

// 聚类核心方法:输入批注数据、距离阈值,返回聚类结果
List<List<Map<String, dynamic>>> clusterAnnotations(List<Map<String, dynamic>> annots, double eps) {
  List<List<Map<String, dynamic>>> clusters = [];
  List<bool> visited = List.filled(annots.length, false);

  for (int i = 0; i < annots.length; i++) {
    if (visited[i]) continue;
    visited[i] = true;
    List<Map<String, dynamic>> neighbors = _getNearbyAnnots(annots, i, eps);
    // 仅保留包含多个批注的聚类
    if (neighbors.length >= 1) {
      List<Map<String, dynamic>> cluster = [annots[i]];
      for (var neighbor in neighbors) {
        int idx = annots.indexOf(neighbor);
        if (!visited[idx]) {
          visited[idx] = true;
          cluster.add(neighbor);
          neighbors.addAll(_getNearbyAnnots(annots, idx, eps));
        }
      }
      clusters.add(cluster);
    }
  }
  return clusters;
}

// 获取指定批注的邻近批注
List<Map<String, dynamic>> _getNearbyAnnots(List<Map<String, dynamic>> annots, int idx, double eps) {
  List<Map<String, dynamic>> result = [];
  Offset targetCenter = annots[idx]['screenCenter'];
  for (int i = 0; i < annots.length; i++) {
    if (i == idx) continue;
    double distance = (annots[i]['screenCenter'] as Offset).distanceTo(targetCenter);
    if (distance <= eps) result.add(annots[i]);
  }
  return result;
}

3. 渲染聚类标记与交互逻辑

在Pdftron视图上方叠加自定义绘制层,根据缩放比例切换显示单个批注或聚类标记:

// 设定触发聚类的缩放阈值
const double clusterZoomThreshold = 0.5;
double currentZoom = 1.0;
List<List<Map<String, dynamic>>> currentClusters = [];

@override
Widget build(BuildContext context) {
  return Stack(
    children: [
      // Pdftron PDF视图
      PdftronFlutter.view(
        onDocumentLoaded: () => _refreshAnnotations(),
        onZoomChanged: (double zoom) {
          currentZoom = zoom;
          if (zoom < clusterZoomThreshold) {
            // 缩小时计算聚类
            currentClusters = clusterAnnotations(annotScreenData, 40);
          } else {
            currentClusters.clear();
          }
          setState(() {});
        },
      ),
      // 聚类标记绘制层
      if (currentZoom < clusterZoomThreshold)
        Positioned.fill(
          child: GestureDetector(
            onTapUp: (TapUpDetails details) => _handleClusterTap(details.localPosition),
            child: CustomPaint(
              painter: ClusterMarkerPainter(currentClusters),
            ),
          ),
        ),
    ],
  );
}

// 处理聚类标记点击:放大到阈值比例并滚动到聚类中心
void _handleClusterTap(Offset tapPos) {
  for (var cluster in currentClusters) {
    Offset clusterCenter = Offset(
      cluster.map((a) => a['screenCenter'].dx).reduce((a,b) => a+b) / cluster.length,
      cluster.map((a) => a['screenCenter'].dy).reduce((a,b) => a+b) / cluster.length,
    );
    if ((tapPos - clusterCenter).distance <= 20) {
      // 转换回PDF坐标并滚动
      PdftronFlutter.setZoom(clusterZoomThreshold);
      PdftronFlutter.scrollTo(
        clusterCenter.dx / clusterZoomThreshold,
        clusterCenter.dy / clusterZoomThreshold,
      );
      break;
    }
  }
}

// 自定义聚类标记绘制器
class ClusterMarkerPainter extends CustomPainter {
  final List<List<Map<String, dynamic>>> clusters;
  ClusterMarkerPainter(this.clusters);

  @override
  void paint(Canvas canvas, Size size) {
    final circlePaint = Paint()..color = Colors.deepOrange.withOpacity(0.8);
    final textPainter = TextPainter(textDirection: TextDirection.ltr);

    for (var cluster in clusters) {
      Offset center = Offset(
        cluster.map((a) => a['screenCenter'].dx).reduce((a,b) => a+b) / cluster.length,
        cluster.map((a) => a['screenCenter'].dy).reduce((a,b) => a+b) / cluster.length,
      );
      // 绘制聚类圆形标记
      canvas.drawCircle(center, 20, circlePaint);
      // 绘制批注数量文本
      textPainter.text = TextSpan(
        text: cluster.length.toString(),
        style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
      );
      textPainter.layout();
      textPainter.paint(
        canvas,
        Offset(center.dx - textPainter.width/2, center.dy - textPainter.height/2),
      );
    }
  }

  @override
  bool shouldRepaint(covariant ClusterMarkerPainter oldDelegate) {
    return oldDelegate.clusters != clusters;
  }
}

4. 关键优化点

  • 动态阈值调整:可根据当前缩放比例动态修改聚类的距离阈值,缩放越小阈值越大,适配不同视图层级
  • 性能优化:批注数量较多时,将聚类计算放在compute异步线程执行,避免UI卡顿
  • 坐标同步:监听PDF视图的滚动、缩放事件,实时更新批注的屏幕坐标,确保聚类位置准确

内容的提问来源于stack exchange,提问作者Ruchit Soni

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 12:14:59