CAD 假表格识别与转化完整技术栈

〇、TL;DR

如果你有一张 AutoCAD 图纸,里面有手工画的"假表格"(用 Line/多段线和文字拼成的),想把它转成 AutoCAD 原生 TABLE 对象(可编辑、可导出),读完这篇文章你就能复现

用户框选(线条+文字)
    │
    ▼
线解析 ──→ 边界聚类 ──→ 网格构建 ──→ 文字映射 ──→ 合并检测
                                                      │
                                                      ▼
                                        AutoCAD TABLE (含 MergeCells)

一、问题定义

什么是"假表格"

AutoCAD 图纸中,工程师经常不创建原生 TABLE 对象,而是手动用线段画出边框,再用 TEXT/MTEXT 填入内容。这样的一张表就是"假表格"(Fake Table)。

假表格 = LINE + LWPOLYLINE + TEXT + MTEXT 的散装组合
真表格 = Autodesk.AutoCAD.DatabaseServices.Table

转化目标

输入: 散装的线 + 文字(用户框选)
输出: 一个 Table 对象 + 所有单元格内容 + 合并单元格信息

二、第一阶段:线解析(Line Parsing)

2.1 实体过滤

var filter = new SelectionFilter(new[] {
    new TypedValue(0, "TEXT,MTEXT,LINE,LWPOLYLINE,POLYLINE")
});

支持四种线段类型和相关文字。

2.2 线段方向判定

// 对于 LINE
if (Math.Abs(l.StartPoint.Y - l.EndPoint.Y) < 5)    // |ΔY| < 5px → 水平线
    hLines.Add((x1, x2, y));
else if (Math.Abs(l.StartPoint.X - l.EndPoint.X) < 5) // |ΔX| < 5px → 竖直线
    vLines.Add((y1, y2, x));

// 对于 Polyline: 逐边遍历
for (int i = 0; i < pl.NumberOfVertices; i++)
{
    var p1 = pl.GetPoint3dAt(i);
    var p2 = pl.GetPoint3dAt((i + 1) % pl.NumberOfVertices);
    // 同 LINE 的判断逻辑
}

容差 5px:绘图时手的抖动误差。

2.3 数据结构

List<(double x1, double x2, double y)>  hLines;  // 横线: 左X, 右X, Y
List<(double y1, double y2, double x)>  vLines;  // 竖线: 下Y, 上Y, X
List<TextEntry>                          texts;   // 文字: X, Y, Content

三、第二阶段:边界提取(Boundary Extraction)

3.1 聚类

// 去掉短线噪声(长度<10px的线段忽略),防止表外线段干扰
var boundaryYs = hLines.Where(h => h.x2 - h.x1 > 10)
                       .Select(h => h.y)
                       .Distinct(new DoubleTolerance(2))    // 2px 容差合并同Y线
                       .OrderByDescending(y => y).ToList();

var boundaryXs = vLines.Where(v => v.y2 - v.y1 > 10)
                       .Select(v => v.x)
                       .Distinct(new DoubleTolerance(2))
                       .OrderBy(x => x).ToList();

3.2 网格定义

boundaryYs[0]───── 第 0 行的顶边
boundaryYs[1]───── 第 0 行的底边 = 第 1 行的顶边
boundaryYs[2]───── 第 1 行的底边
...
boundaryYs[N]───── 最后一行底边

rowCount = boundaryYs.Count - 1   ← 线段间空隙数 = 行数
colCount = boundaryXs.Count - 1   ← 同理

3.3 边界中点

// N 条边界 → N-1 个行中心
var rowCenters = new List<double>();
for (int i = 0; i + 1 < boundaryYs.Count; i++)
    rowCenters.Add((boundaryYs[i] + boundaryYs[i + 1]) / 2);

用于后续的容差计算。


四、第三阶段:文字映射(Text-to-Grid)

4.1 区间定位法

文字不属于最近的格子中心——文字被哪四条边界围住,就属于哪格子。

// text.Y 落在 [boundaryY[r], boundaryY[r+1]) → 行 r
int FindInterval(List<double> sorted, double val, bool ascending)
{
    for (int i = 0; i + 1 < sorted.Count; i++)
    {
        if (ascending)
        { if (val >= sorted[i] && val < sorted[i + 1]) return i; }
        else
        { if (val <= sorted[i] && val > sorted[i + 1]) return i; }
    }
    return -1;  // 表外文字 → 忽略
}

4.2 实际使用

foreach (var txt in texts)
{
    int r = FindInterval(boundaryYs, txt.Y, descending: true);
    int c = FindInterval(boundaryXs, txt.X, ascending: true);
    if (r < 0 || c < 0) continue;  // 表外文字丢弃
    grid[r, c] += txt.Content;
}

五、第四阶段:合并单元格检测

这是本文的重点,也是算法最精巧的部分。

5.1 核心假设

在正常(无合并)区域,每条格子上只有一条切割线。如果某条线上某个区间没有线段,那一定是被合并单元格"占用"了。

这条假设意味着:我们不需要看文字,不需要算距离——只看线有没有缺口。

5.2 预索引

// 横线按 Y 分组,竖线按 X 分组
var hDict = new Dictionary<int, List<(double x1, double x2)>>();
foreach (var hl in hLines)
{
    int k = (int)Math.Round(hl.y);
    hDict.AddOrUpdate(k, hl);
}

var vDict = new Dictionary<int, List<(double y1, double y2)>>();
foreach (var vl in vLines)
{
    int k = (int)Math.Round(vl.x);
    vDict.AddOrUpdate(k, vl);
}

5.3 横边界扫描(找列方向合并)

for (int r = 0; r <= rowCount; r++)   // 遍历每条横边界
{
    int yKey = (int)Math.Round(boundaryYs[r]);
    if (!hDict.TryGetValue(yKey, out var segs)) continue;

    int p = 0;
    while (p < colCount)
    {
        // 找有线覆盖的连续列
        int q = p;
        while (q < colCount && segs.Any(s => s.x1 <= boundaryXs[q] + 3
                                           && s.x2 >= boundaryXs[q+1] - 3))
            q++;

        if (q > p) { p = q; continue; }  // 有线区域,跳过

        // 找无线覆盖的连续列 → 合并检测
        q = p + 1;
        while (q < colCount && !segs.Any(s => s.x1 <= boundaryXs[q] + 3
                                            && s.x2 >= boundaryXs[q+1] - 3))
            q++;

        // 缺口 [p, q-1] → merge (行r, 列p..q-1)
        merges.Add((
            r > 0 ? r - 1 : 0,
            p,
            r < rowCount ? r : rowCount - 1,
            q - 1
        ));
        p = q;
    }
}

5.4 竖边界扫描(找行方向合并)

同理,遍历每条竖边界 boundaryXs[c],在行方向上找无线覆盖的行区间。

5.5 连通合并(拼碎片)

横边界和竖边界可能分别检测到相邻的合并片段。需要拼成一个完整矩形:

// 四方扩张:向上/下/左/右检测无线 → 扩张
// 若碰到已有合并区 → 合并为更大矩形
while (cL > 0   && !_yEdge(vDict, cL - 1, rT, rB, ...)) cL--;   // ←
while (cR + 1 < N && !_yEdge(vDict, cR    , rT, rB, ...)) cR++;  // →
while (rT > 0   && !_xEdge(hDict, rT - 1, cL, cR, ...)) rT--;   // ↑
while (rB + 1 < M && !_xEdge(hDict, rB    , cL, cR, ...)) rB++;  // ↓

⚠️ 关键细节:Expansion Boundary 索引

  • _xEdge(hDict, r-1, ...):向上扩张检查的是第 r-1 行的边界(= 当前 r 行的上边界)
  • _yEdge(vDict, c-1, ...):向左扩张检查的是第 c-1 个竖边界

曾在这里犯过 off-by-1 的 bug,导致合并区多吞一行。

5.6 文字汇总

foreach (var (sR, sC, eR, eC) in merges)
{
    var main = grid[sR, sC];
    for (int r = sR; r <= eR; r++)
        for (int c = sC; c <= eC; c++)
            if (grid[r, c] != main && !string.IsNullOrEmpty(grid[r, c]))
                main = string.IsNullOrEmpty(main) ? grid[r, c]
                                                   : main + "\n" + grid[r, c];
    grid[sR, sC] = main;
    // 子格清空
    for (int r = sR; r <= eR; r++)
        for (int c = sC; c <= eC; c++)
            if (r != sR || c != sC) grid[r, c] = "";
}

六、第五阶段:生成 AutoCAD TABLE

6.1 建表

var cadTable = new Table();
cadTable.SetSize(totalRows, totalColumns);
cadTable.Position = insertPoint;

// 填入文字
for (int row = 0; row < totalRows; row++)
    for (int col = 0; col < totalColumns; col++)
        cadTable.Cells[row, col].TextString = data.Rows[row][col]?.ToString() ?? "";

6.2 合并单元格

foreach (var (sR, sC, eR, eC) in result.MergedRanges)
{
    var range = CellRange.Create(cadTable, sR, sC, eR, eC);
    cadTable.MergeCells(range);
}

CellRange.Create(table, topRow, leftCol, bottomRow, rightCol) 需要传入 table 对象。


七、踩坑历程

算法不是一次写对的。我们先后尝试了 6 种方案,每次都以为"这次肯定行了",然后被实际运行结果打脸。以下是完整的失败→反思→重试记录。

坑 1:最近中心点法(文字映射失败)

初始想法:这应该是最自然的思路——算出每个格子的中心点坐标,然后把文字归类到"距离最近"的中心点对应的格子。简单的欧氏距离,完美。

实现

foreach (var txt in texts) {
    double bestDist = double.MaxValue;
    for (int r = 0; r < rowCount; r++)
        for (int c = 0; c < colCount; c++) {
            double dy = Math.Abs(txt.Y - rowCenters[r]);
            double dx = Math.Abs(txt.X - colCenters[c]);
            if (dy + dx < bestDist) { bestRow = r; bestCol = c; bestDist = dy + dx; }
        }
    grid[bestRow, bestCol] = txt.Content;
}

运行结果:文字被错位分配到相邻格子。

为什么失败:我们默认文字在格子正中心,但用户画图时文字可能偏左、偏上、偏任何位置。为了测试程序鲁棒性,用户故意把所有文字在格子内偏移了位置——结果"最近中心"指向了错误格。中心点法是"理想化假设",真实世界里文字位置不确定。

坑 2:文字→右/下单向扩张(合并漏判)

初始想法:既然文字映射先不做,那就先做合并检测。从每个格子的文字出发,向右边和下方"探头"——如果没撞到横线,就扩张一行;没撞到竖线,就扩张一列。合并区就是文字为中心向右下扩张的矩形。

实现

int ec = c;
while (!HasVertLine(r, ec)) ec++;  // 向右扩
int er = r;
while (!HasHorzLine(er, c)) er++;  // 向下扩
merges.Add((r, c, er, ec));

运行结果A2:A3 合并(两行一列)能正确识别,但 C4:D6(两列三行合并)只识别出 D6:D6。第一次扩张后合并盒的右下角停在了文字所在格,上/左方向完全漏掉。

为什么失败:算法假设合并区域内文字总在左上主格。实际用户把文字写在了合并区的右下角子格。从右下角出发只向右/下扩张,永远碰不到真实的左上边界。单向扩张不能处理文字在合并区非左上角的情况。

坑 3:中位数标准尺寸法(误判合并)

初始想法:既然每个人画的表格行高/列宽不一样,干脆用统计学。算所有行高的中位数当成"标准行高",然后 实际行高 ÷ 标准行高 大于 1.5 即为合并行。同样处理列。

实现

double stdRowH = Median(rowHeights);
int span = (int)Math.Round(rowHeight[r] / stdRowH); // span=2 → 2行合并

运行结果A2:A3C4:D6 都识别成功(行高 100 vs 标准 50 → ratio=2),但普通格子里有一行稍宽的(55 vs 50 → ratio=1.1 → 被误判合并,或 60 vs 50 → 按 Round 误判 2 行)。

为什么失败:用户没有"标准行高"。他们画的每一行高度可以任意调整,设计美学需要某些行更高。基于统计的平均/中位数假设在 CAD 手工画图中完全不成立。任何基于"标准尺寸"的推断在自由画图场景下都会失效。

坑 4:全格布尔矩阵 O(R×C)(性能差 + 噪音大)

初始想法:预建 hasVert[r][c] 矩阵——对每个格子的四条边,都预判是否有线覆盖。然后遍历所有非空格子做四方扩张。O(R×C) 的密集矩阵,但关键是预计算

实现

bool[,] hasVert = new bool[rowCount, colCount];
for (int r = 0; r < rowCount; r++)
    for (int c = 0; c < colCount; c++)
        hasVert[r, c] = vLines.Any(l => Math.Abs(l.x - boundaryXs[c+1]) < 3 ...);

运行结果:对 8×4 表格没问题。但模拟 500×500 的大型建筑用料表时,Any() 里的线扫描在内外双重嵌套中爆炸,单次调用即耗时 >5 秒。

为什么失败Any() 每次遍历 ALL 线段(50 条 × 250000 格 × 4 方向 = 五千万次 O(V))。耦合密度和表的尺寸关系是 O(R×C×L) 而不是 O(R+C)。全格子密集扫描对大数据表格是不可行的。

坑 5:多信号融合法(规则互相打架)

初始想法:既然一条信号不够,就用三条——线缺失 + 文字内容相同 + 几何联通性,三个信号都命中才判合并。

实现

bool lineMissing = !HasVertLine(...);
bool sameText = leftCell.Text == rightCell.Text;
bool geometryConnected = IsInSameBlock(leftCell, rightCell);
if (lineMissing && sameText && geometryConnected) Merge();

运行结果:文字内容相同这条规则自身有极大的假阳性——"5" 这个数字在多行出现只是巧合,不等于合并。几何联通性在网络线段中定义模糊。三条规则的阈值分别调整,调试时互相干涉,像一个三体问题——改了一个参数,另一个就废了。

为什么失败:规则越多,阈值调优空间越大,但对实际场景的覆盖率反而越低。简单正确的一条规则,比几十条模糊规则加权好得多。

坑 6:Dictionary 取整键 + off-by-1(查询错位)

初始想法:用 Math.Round(x/3)*3 作为 Dictionary 键,通过取整做模糊匹配。

实现

var key = Math.Round(vl.x / 3) * 3;   // vl.x=100.0 → key=99
// ...
var lookup = Math.Round(boundaryXs[c] / 3) * 3;  // boundaryXs=99.5 → key=99 ✓
// 但 boundaryXs=100.0 → key=99 ✗ (线与边界在同一位置但也查不到)

运行结果:边界上有线但查不到,合并检测完全错乱。

为什么失败:取整造成的位置偏差在"2.5px-5px 区间"有歧义,恰好处在临界点的线段被丢失。加上扩张时的 off-by-1(见下方),导致合并区向上多吞了一行。

off-by-1 具体案例

// 错误: _xEdge(hDict, rT, ...) 检查 bY[rT+1](本行的下边界)
// 正确: _xEdge(hDict, rT-1, ...) 检查 bY[rT](本行的上边界)
// 结果: header 行被错误地并入 A2:A3 合并区

八、最终收敛

经过 6 次挫败,我们回归到了最本质的问题:

  1. 文字的精确位置不重要——只需要知道它在哪个边界区间内
  2. 合并检测不看文字——只看线的空缺
  3. 不做任何统计假设——标准尺寸不存在
  4. 一条规则就够了——缺口 = 合并

这条"连续线段假设"不是拍脑袋想出来的,而是从 6 次失败中反向推导出的唯一能在所有测试场景中一致的逻辑。算法最终形态(见第五、第六章)就是这些教训的产物。


九、性能

步骤复杂度1000×1000 表耗时
线聚类O(L log L)< 5 ms
边界提取O(L)< 1 ms
文字映射O(T log(RC))< 10 ms
边界扫描O((R+C) × K) K=该行有线数< 5 ms
总计< 30 ms

十、复现指南

环境

  • .NET 8.0+ 或 .NET Framework 4.8
  • AutoCAD 2015+ ObjectARX SDK
  • 项目:CadQuantityTakeoffToolbox

关键文件

Commands/
  └─ 6.数据中心-6.1数据导入导出(SJDRYDC)_DataExchange.cs
       ├─ LoadFromFakeTable()      → 假表格识别主入口
       ├─ FindInterval()            → 文字区间定位
       ├─ BuildCentersBetween()     → 边界中点计算
       ├─ DoubleTolerance           → 容差比较器
       └─ 合并检测部分 (lines 410-526) → 核心算法

触发方式

[CommandMethod("SJDRYDC")] // AutoCAD 命令入口
→ 选择 "文字直线型表格" 作数据源
→ 选择 "CAD原生表格" 作导出格式
→ 自动运行完整管线

十一、致谢

  • AutoCAD 开发者社区关于 Table.IsMergedCellCellRange 的讨论
  • 掘金《CAD二次开发 C# 线段表格识别出excel》(2023)提供的合并检测启发
  • 用户一针见血的直觉——"只有没线的区域才可能是合并的"——这是本文核心假设的来源

最后修改: 2026-07-29
作者: CAD算量工具箱团队

评论

« 返回