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:A3 和 C4: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 次挫败,我们回归到了最本质的问题:
- 文字的精确位置不重要——只需要知道它在哪个边界区间内
- 合并检测不看文字——只看线的空缺
- 不做任何统计假设——标准尺寸不存在
- 一条规则就够了——缺口 = 合并
这条"连续线段假设"不是拍脑袋想出来的,而是从 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.IsMergedCell、CellRange的讨论 - 掘金《CAD二次开发 C# 线段表格识别出excel》(2023)提供的合并检测启发
- 用户一针见血的直觉——"只有没线的区域才可能是合并的"——这是本文核心假设的来源
最后修改: 2026-07-29
作者: CAD算量工具箱团队
评论