CAD二次开发DBText对齐方式与批量居中实战
把一条“把图签文字全部居中”的脚本交给同事之后,我以为这事就完了。第二天他把测试图甩回来,图框里二十几个字段全挤到了坐标原点附近,像被人拿橡皮擦推了一把。我第一反应是“插入点算错了”,打开代码一看,插入点算得没错,问题出在他只改了Position,没碰对齐方式。这个坑在 CAD 二次开发里属于超高频项——DBText的对齐方式看起来只是一个枚举属性,实际上它牵扯到两套坐标体系、一个只读快照和一个必须手动调用的重算方法。这篇就把DBText对齐方式这件事从底层捋一遍,不管你是刚接手 CAD 插件维护,还是准备写批量改字脚本,都能直接拿去用。
1. 单行文字的两套坐标:Position 与 AlignmentPoint 到底谁说了算
1.1 改了 Position 文字不动,是因为对齐方式在“抢方向盘”
DBText身上有两个Point3d类型的属性,名字看着都想当“位置”,但它们不是平等的。Position是文字的插入点,AlignmentPoint是文字的对齐点。哪一个是真正决定文字视觉位置的,取决于当前的对齐方式组合。
规则很干脆:当水平方式为TextLeft且垂直方式为TextBase(也就是最朴素的“基线左对齐”)时,Position生效,AlignmentPoint被彻底忽略,值往往是(0,0,0)。只要二者中有一个不是默认值,AlignmentPoint立刻接管,Position反过来变成被忽略的那一个,由平台根据对齐点反算出来。
这就解释了为什么会出现“代码跑完了,日志显示 Position 赋值成功,屏幕上文字纹丝不动”的现象。你赋值的那一行确实执行了,属性也确实变了,只是渲染时不看它。更隐蔽的是相反的情况:一个文字之前被设过TextCenter,你后来想把整个元素挪走,改Position——结果它只在自己的对齐点周围原地不动。很多人这时候会怀疑是不是事务没提交、是不是图层被锁定,排查半天,其实原因在最基础的对齐语义上。
我一般会在项目里强制约定一条规矩:要么全程用TextLeft+TextBase只操作Position,要么全程用对齐方式只操作AlignmentPoint,绝不混用。混用是绝大多数“文字位置诡异”问题的根源。
1.2 Justify 是只读快照,HorizontalMode 和 VerticalMode 才是真正的开关
新手最容易写的代码是text.Justify = AttachmentPoint.MiddleCenter;。这行编译不过,因为DBText.Justify在 .NET 接口里是只读的get属性。它返回的是一个AttachmentPoint枚举,作用是把“水平方式 + 垂直方式”这个二维组合压扁成一个单值,方便你读取当前状态,仅此而已。
真正可写的是两个正交属性:
| 属性 | 枚举类型 | 可取值 | 说明 |
|---|---|---|---|
HorizontalMode | TextHorizontalMode | TextLeft/TextCenter/TextRight/TextAlign/TextMid/TextFit | 控制水平锚定 |
VerticalMode | TextVerticalMode | TextBase/TextBottom/TextVerticalMid/TextTop | 控制垂直锚定 |
TextMid和TextCenter在绝大多数场景下表现一致,属于历史遗留的两套命名,实际项目里统一用TextCenter就行,没必要去抠那点差异。真正需要单独拎出来的是TextAlign和TextFit,这两个不是“锚定”而是“拉伸”,后面单独开一节讲。
垂直方向有一个特别容易忽略的点:TextBase锚的是基线,不是文字底部。字母 g、y、p 这些带下伸部的字符会越过基线往下掉。如果你做的是“把文字放在矩形框正中间”这类需求,用TextBase算出来的中心会整体偏上一点。要让视觉重心落在几何中心,垂直方向应该用TextVerticalMid。
1.3 用 DXF 组码把这件事一次说透
图形数据落到存储层,对齐方式体现为三个组码:
| 组码 | 含义 | 取值 |
|---|---|---|
| 10/20/30 | 第一对齐点(插入点) | 坐标值 |
| 11/21/31 | 第二对齐点(对齐点) | 坐标值 |
| 72 | 水平方式 | 0 左 / 1 居中 / 2 右 / 3 对齐 / 4 中间 / 5 适配 |
| 73 | 垂直方式 | 0 基线 / 1 底 / 2 中 / 3 顶 |
底层的判定逻辑只有一句:72 和 73 同时为 0 时,以组码 10 为准,11 被忽略;否则以组码 11 为准,10 由平台反算填充。
把这张表记住,很多现象就不用猜了。比如你从别的图纸里复制过来的文字,一读出来AlignmentPoint是(0,0,0),说明它就是默认左基线对齐;再比如你发现某个文字改了Position之后,重新打开图纸位置又变了,因为保存时平台只写了组码 11,你写进 10 的值在下次加载时被重算覆盖。理解这一层,排查的时候可以直接把焦点从“我的代码是不是写错了”转到“当前这个文字到底处在哪种锚定模式”。
2. AdjustAlignment 的调用时机与三个必踩的异常
2.1 设置、赋值、重算:顺序错了等于白写
改对齐方式的完整动作是四步,缺一步都不行:
// 数据库与事务对象来自宿主 CAD 平台的 .NET 接口 using (Transaction tr = db.TransactionManager.StartTransaction()) { DBText txt = tr.GetObject(id, OpenMode.ForWrite) as DBText; // 第一步:切到目标对齐方式 txt.HorizontalMode = TextHorizontalMode.TextCenter; txt.VerticalMode = TextVerticalMode.TextVerticalMid; // 第二步:告诉它锚点应该在哪儿 txt.AlignmentPoint = targetCenter; // 第三步:让它以锚点为准,反算插入点 txt.AdjustAlignment(db); tr.Commit(); }第三步的AdjustAlignment(db)是关键。这个方法做的事情,是按当前的对齐方式重新计算Position,使得文字的视觉位置与AlignmentPoint保持一致。不调用它,属性值虽然都改了,但Position还是旧值,渲染时就会出现“文字跳到了奇怪的地方”或者“看起来完全没动”两种表现,取决于平台内部缓存的具体状态。
调用顺序也不能乱。实践中正确的写法只有一种:改对齐方式 → 赋对齐点 → 调AdjustAlignment。先调AdjustAlignment再赋AlignmentPoint,重算用的是旧锚点,白算一次;只赋AlignmentPoint不调重算,位置可能对但旋转、宽度因子相关的偏移会失准。
注意:
AdjustAlignment需要传入的Database必须是这个DBText所属的那个库。跨图纸复制文字时如果用当前文档的Database,会抛异常或者算出离谱的结果。稳妥写法是txt.Database,别图省事写死Application.DocumentManager.MdiActiveDocument.Database。
2.2 三个必踩异常:未入库、只读、事务外调用
未入库实体。新建的DBText在BlockTableRecord.AppendEntity()之前,Database属性是null。这时候调AdjustAlignment,要么空引用,要么抛eNotInDatabase之类的异常。正确顺序是先AppendEntity,再AddNewlyCreatedDBObject,最后才调整对齐。我见过有人为了“先算好位置再入库”把调整逻辑提前,结果始终报错。
只读打开。tr.GetObject(id, OpenMode.ForRead)拿到的对象,设置HorizontalMode直接抛eNotOpenForWrite。这个好排查,报错信息明确。真正阴险的是:有些人为了性能用StartOpenCloseTransaction做批量只读扫描,然后顺手在里面写了一行调整对齐,异常抛得莫名其妙——StartOpenCloseTransaction开出来的对象语义和普通事务不一样,写操作的行为不保证。批量扫描用StartOpenCloseTransaction可以,但任何写操作都放回普通事务里做。
事务外调用。对象的属性设置在事务外不是必须报错,但AdjustAlignment依赖事务上下文的可能性很高,尤其在批量场景下平台内部会做惰性求值。老老实实把整段逻辑放在using块里,别搞“先收集好参数,出了事务再统一处理”这种花活。
2.3 一份能直接跑的批量居中脚本
把上面的规则拼起来,就是一个完整的工具方法。我习惯写一个静态辅助类,全项目复用:
public static class TextAligner { /// <summary>把单行文字的对齐锚点移到指定点</summary> public static void AlignTo(DBText txt, Point3d anchor, TextHorizontalMode hMode, TextVerticalMode vMode, Database db) { bool isDefault = txt.HorizontalMode == TextHorizontalMode.TextLeft && txt.VerticalMode == TextVerticalMode.TextBase; // 记录调整前的视觉锚点,用于后续校验 Point3d before = isDefault ? txt.Position : txt.AlignmentPoint; // TextAlign / TextFit 是消耗两个点的模式,需要特殊处理 if (hMode == TextHorizontalMode.TextAlign || hMode == TextHorizontalMode.TextFit) { txt.Position = before; txt.HorizontalMode = hMode; txt.AlignmentPoint = anchor; } else { txt.HorizontalMode = hMode; txt.VerticalMode = vMode; txt.AlignmentPoint = anchor; } txt.AdjustAlignment(db); } }调用侧做批量处理时,注意先收集ObjectId,再统一开事务处理。边遍历模型空间边改属性,遇到集合被修改会抛eWasErased或者枚举失效。这个小细节在“只有十几条文字”的时候完全感知不到,到几千条的时候必现。
3. TextAlign 与 TextFit:两种“假对齐”带来的字形灾难
3.1 一个拉字间距,一个改字高,都不是锚定
TextAlign(组码 72=3)和TextFit(72=5)在枚举列表里排在中间,很容易被当成普通对齐方式一起用。它们的行为完全不同:
TextAlign:文字在Position和AlignmentPoint两点之间均匀分布,字高不变,字间距被拉开或压缩。TextFit:文字在两点之间填满,同时调整字高和字间距,字高会被拉伸或压缩。
两者都需要两个点同时有效,且都不走“锚点反算”的逻辑。如果你对一条文字先设了TextCenter,再改成TextFit并只赋了AlignmentPoint,另一个点还是旧值,结果就是文字被拉成一条诡异的压缩带。
我处理过一个图纸规范化的需求,要求“所有标注文字统一 3.5 号字、居中”。初版脚本偷懒,把所有文字统一切到TextFit再塞两个端点,跑完之后图纸里所有文字的字高都不一样了——有的被拉高到 5 号,有的被压到 2 号,因为原来文字长度不同,两点间距也不同。这个错误在缩略图里看不出来,打印出来立刻露馅。
提示:只要你的需求里出现了“统一字号”“字号必须一致”这类约束,就绝对不能用
TextFit。它能做的只是在视觉上填满一段区间,代价是牺牲字号一致性。
3.2 什么场景下这两个反而是最优解
不能一棍子打死。TextAlign在几个边缘场景里其实很好用:
一是等宽字段占位。图签里“设计”“审核”“批准”这类固定宽度的格子,要求文字两端都贴边但不改字号,TextAlign一键搞定,比手动调字间距快得多。
二是沿曲线的文字段落。虽然单行文字不跟随曲线,但把一段短文字拆成多条DBText沿路径分布时,用TextAlign可以让每条文字填满自己的分割区间,视觉上更像连续排版。
三是旧图迁移。有些早期图纸里大量使用 72=3 / 72=5,做批量规范化时不能简单粗暴地统一改成TextCenter,那会让文字位置全部跳变。正确的做法是读原有两个端点,取中点作为新的锚点,再切到TextCenter+TextVerticalMid。
if (txt.HorizontalMode == TextHorizontalMode.TextAlign || txt.HorizontalMode == TextHorizontalMode.TextFit) { Point3d p1 = txt.Position; Point3d p2 = txt.AlignmentPoint; Point3d mid = new Point3d((p1.X + p2.X) / 2.0, (p1.Y + p2.Y) / 2.0, (p1.Z + p2.Z) / 2.0); TextAligner.AlignTo(txt, mid, TextHorizontalMode.TextCenter, TextVerticalMode.TextVerticalMid, txt.Database); }3.3 判断与回退的完整策略
批量处理里我会写一个统一入口,先嗅探当前状态,再决定走哪条路:
| 当前状态 | 处理方式 | 理由 |
|---|---|---|
| 72=0 且 73=0 | 直接读Position,切模式后赋AlignmentPoint | 默认模式,锚点是 Position |
| 72=3 或 72=5 | 取两点中点,再切模式 | 两个点都有效,中点最接近原视觉中心 |
| 其他组合 | 直接读AlignmentPoint | 锚点已生效 |
这套判断逻辑写完之后,同一份脚本跑在新图和老图上都不会出问题。我建议把它封装成一个GetVisualAnchor(DBText)方法,全项目只此一份,别每个业务函数里各写一遍——我接手过的项目里同一个判断逻辑出现了七份,其中三份漏了TextFit分支。
4. 把文字对齐到几何体:包围盒、锚点与偏移量的计算
4.1 GeometricExtents 的边界误差比你想的大
需求里十有八九是“把文字放到这个框的正中心”,而不是“把文字的对齐点放到某个坐标”。这两件事的差别,就在GeometricExtents上。
DBText.GeometricExtents返回一个Extents3d,包含MinPoint和MaxPoint。取中点算出来的是包围盒中心,跟“对齐点放在包围盒中心”不是一回事。对于TextCenter+TextVerticalMid这种组合,平台内部的锚点计算用的是字高和字形度量,和包围盒会有细微偏差;遇到带下伸部字符(g、y、p、q)或者含空格结尾的字符串,偏差会被放大。
三个已知的误差来源:
第一个是字高的一半 vs 包围盒的一半。TextVerticalMid在平台内部大概率是以“字高的一半”为基准,而GeometricExtents的高度包含了字形实际范围,两者可能相差几个百分点。
第二个是空格与尾部留白。字符串末尾如果有空格,包围盒会把它算进去,但视觉上你是看不见的。
第三个是宽度因子和倾斜。设了WidthFactor = 0.8或者Oblique之后,包围盒随之变化,而对齐点计算用的度量是否同步,取决于平台实现。
实践中的结论:要精度就走“包围盒 + 手动平移”,要速度就直接用锚点对齐。前者多一次测量和一次平移,后者一步到位但可能差一两个像素。绝大多数图纸审核标准容忍这个误差,但如果你的场景是“文字必须严格居中于印章框”,别省那一步。
4.2 用包围盒反算的通用公式
要做精确居中,思路是把文字先归位到已知状态,测量,再平移。平移时必须走对齐点这条路——因为你已经切到了非默认对齐方式,改Position是没用的。
// 目标:让文字的几何中心精确落在 targetCenter public static void CenterTo(DBText txt, Point3d targetCenter, Database db) { // 先切成居中锚定,锚点随便给个值 txt.HorizontalMode = TextHorizontalMode.TextCenter; txt.VerticalMode = TextVerticalMode.TextVerticalMid; txt.AlignmentPoint = txt.Position; // 此处用旧 Position 兜底 txt.AdjustAlignment(db); Extents3d ext; try { ext = txt.GeometricExtents; } catch { return; } // 极少数情况下包围盒不可用,直接放弃精修 Vector3d half = (ext.MaxPoint - ext.MinPoint) * 0.5; Point3d boxCenter = ext.MinPoint + half; Vector3d delta = targetCenter - boxCenter; txt.AlignmentPoint = txt.AlignmentPoint + delta; txt.AdjustAlignment(db); }这段代码有一个隐蔽前提:GeometricExtents要求实体已经入库且图形数据可用。对刚AppendEntity还没提交的对象调用,可能抛eNullExtents。所以真实项目里我会在try/catch里包一层,兜底直接返回,不让一条坏数据打断整批处理。
注意:
Extents3d类型本身没有Center属性,中点必须自己用MinPoint + (MaxPoint - MinPoint) * 0.5算。这个Vector3d支持乘法的细节,很多人第一次写会写成逐个分量计算,虽然结果一样,但可读性差很多。
4.3 表格与图框场景的实操参数
真实项目里最常见的两个场景,参数取值其实很固定:
场景一:表格单元格居中。已经有单元格的四个角点,直接取几何中心作为targetCenter,对文字用TextCenter+TextVerticalMid,几乎不需要二次修正。如果单元格高度小于 2 倍字高,垂直方向建议改成TextBottom并把目标点往下移 10% 单元格高度,视觉上更舒服——纯几何居中在窄格里看起来会偏下,这是排版上的老经验,跟平台无关。
场景二:图框图签字段。这类字段通常有严格的左右边界(比如“图名”格子左边距固定 3mm)。这时候不要居中,用TextLeft+TextVerticalMid,锚点设成“格子左边线 + 左边距”,横向对齐比居中更符合制图习惯。垂直方向用TextVerticalMid而不是TextBase,同样是为了避开基线偏移。
顺便提一个数值上的坑:图框尺寸通常以毫米为单位,而某些模板会把模型空间单位设成米。同一条文字,在单位不同的两张图里,字高相差 1000 倍,居中算出来的偏移量也跟着差 1000 倍。项目里最好加一层单位校验:读一下Database.Insunits,如果不是预期的毫米、厘米这类,先做换算再算几何,否则调半天调不出效果。
5. 单行文字与多行文字的对齐模型差异
5.1 MText 是九宫格,DBText 是二维组合
MText的对齐用Attachment属性,枚举AttachmentPoint有九个值:TopLeft、TopCenter、TopRight、MiddleLeft、MiddleCenter、MiddleRight、BottomLeft、BottomCenter、BottomRight。它是读写的,赋值即生效,改完之后Location的语义会跟着变——Location始终是那个被选中的锚点位置,不是左上角。
对比之下,DBText的Justify属性虽然也返回AttachmentPoint类型,但它是只读的,且这个枚举在单行文字语境下的含义和MText的九宫格不完全等价。DBText真正的控制入口是HorizontalMode+VerticalMode两个独立枚举。
这个不对称性直接导致一个经典 bug:写了个泛型方法同时处理两种文字,代码写成if (obj is MText) {...} else if (obj is DBText) {...},两个分支里对Attachment和Justify的处理逻辑被复制粘贴错了位置。因为两个属性名字不一样、类型一样,编译器不会报错,你只会在运行后发现某一类文字全跑偏。
我在项目里统一的做法是写一层适配:
public interface ITextAnchor { Point3d GetAnchor(); void SetAnchor(Point3d p, Database db); }两种文字各自实现,业务层只调接口,不碰具体属性。这层抽象多写三十行,能省掉后面无数次“这条是 MText 还是 DBText”的排查。
5.2 混排图纸批量统一的落地思路
实际图纸里,同一个字段可能是DBText,也可能被编辑成了MText(双击编辑时有些平台会自动转换)。批量统一的对齐策略要分三步走:
第一步,先分类统计。遍历一遍,按类型和对齐状态分组,把统计结果打日志。这一步不做修改,只读。用StartOpenCloseTransaction跑,速度快。
第二步,按组处理。DBText组走第 2 节那套四步流程;MText组直接设Attachment,注意设完之后还要调一次AdjustAlignment——MText同样有这个需求,只是很多人不知道。MText.AdjustAlignment(db)的行为和DBText版本一致,以Location为准重算落位。
第三步,抽样校验。处理完成后随机抽若干条,读回对齐状态和位置,和预期值比对。差异超过阈值(比如 0.01 单位)的记录下来人工看。这一步在测试环境极其重要,因为对齐问题在缩略图上完全看不出来,只有精确比对才能发现。
还有一个容易忽略的兼容点:MText的Width属性如果是 0,表示不自动换行;如果非 0,文字会在指定宽度内折行,折行后的锚点语义会变。把一组MText统一切到MiddleCenter之前,先确认它们的Width都是 0,否则居中结果会按“块状文字的包围盒”算,和你的预期差一截。
6. 批量处理几万条文字时的性能与稳定性经验
6.1 事务粒度决定成败,Undo 记录是隐形开销
一次处理上千条文字,最容易犯的错是“一个大事务包到底”。看着干净,实际有两个问题:一是内存膨胀,每条被OpenMode.ForWrite打开的对象都留在事务的对象表里,几千条之后内存占用上去得很明显;二是撤销体验灾难,用户点一次撤销,几万条修改一次性回滚,中间没有任何缓冲。
我现在的做法是分批提交,每 2000 到 5000 条开一个事务:
const int BatchSize = 2000; for (int i = 0; i < ids.Count; i += BatchSize) { using (Transaction tr = db.TransactionManager.StartTransaction()) { int end = Math.Min(i + BatchSize, ids.Count); for (int k = i; k < end; k++) { DBText txt = tr.GetObject(ids[k], OpenMode.ForWrite) as DBText; if (txt == null) continue; TextAligner.AlignTo(txt, targetPt, TextHorizontalMode.TextCenter, TextVerticalMode.TextVerticalMid, db); } tr.Commit(); } }批大小没有绝对最优值,2000 是我在多个项目里试出来的折中点:再小则事务开销占比高,再大则单次撤销粒度太粗。如果你的图纸文字量在几百条以内,直接一个事务也无所谓。
另一个隐形开销是Undo 记录。文档级事务会自动生成 Undo 记录,方便用户撤销。批量脚本运行时这部分开销不小。如果这个操作明确不需要撤销(比如一次性数据迁移),可以在执行期间临时关闭撤销记录,跑完再恢复。这个开关在不同平台上接口名不一样,但思路一致:拿到文档的撤销控制对象,Start/End包一层。
还有一个细节:循环里绝对不要调用刷新或重绘。有人为了让用户“看到进度”,在循环体里加一句刷新,几千次刷新下来直接卡死。要做进度就更新状态栏文字,别碰图形刷新。
6.2 字体度量缺失导致的偏移,以及一次真实的翻车
前面讲的都是坐标计算,真正让对齐结果“看起来不对”的,往往是字体。
文字的包围盒依赖于当前文字样式引用的字体文件。如果字体缺失,平台会用替代字体渲染,度量随之改变。脚本在开发机上跑得好好的,部署到另一台机器上文字整体偏了半个字宽——原因就是那台机器没装原字体。
应对策略有三条:
第一,处理前先校验字体。读TextStyle的字体文件名,检查文件是否存在(或者至少统计一下有多少条文字引用了可能缺失的样式),在日志里给出提示。这比事后再排查省事得多。
第二,尽量用锚点对齐而非包围盒对齐。锚点对齐的度量由平台内部按字高算,对字体缺失的敏感度远低于包围盒。精度要求不极端的场景,这个选择能显著提升跨机器一致性。
第三,大字体(亚洲字符集)单独测。中文字符的宽度通常是字高的整数倍,而拉丁字母是比例宽度。同一套代码在西文上居中完美,切到中文可能就差一点。测试用例里必须包含纯中文、纯西文、中英混排三种。
说个具体的翻车经历。有次给某设计单位做图签规范化,本地测试全部通过,交付后对方反馈“图名一栏的文字总是偏右 1 毫米左右”。查了两天,最后发现是那个项目专用的文字样式里挂了自定义字宽比例,而这个比例在包围盒计算时的参与方式和我的预期不一致。最后的修法很土:放弃包围盒,改成“固定左边距 +TextLeft锚定”,问题立刻消失。这件事之后我形成了一个习惯——凡是有明确排版规范的图纸,优先用左对齐 + 固定边距,别追求几何居中。规范是人定的,人的眼睛对左边距整齐的敏感度远高于对居中偏差的敏感度。
6.3 上线前的自查清单
一份我实际在用的检查清单,逐项过一遍能挡掉九成问题:
| 检查项 | 判断方法 | 不合格后果 |
|---|---|---|
是否调用了AdjustAlignment | 搜索代码里对齐相关分支 | 文字位置与预期不符 |
传的Database是否正确 | 是否用txt.Database | 跨图复制时抛异常 |
是否处理了TextAlign/TextFit | 检查嗅探分支 | 老图迁移后字高混乱 |
是否混用了Position与AlignmentPoint | 代码审查 | 修改无声失效 |
| 事务分批是否到位 | 看批大小常量 | 大批量时内存暴涨 |
| 是否包含中文测试用例 | 检查测试数据 | 中文场景偏移 |
| 字体缺失是否有兜底 | 检查异常处理 | 换机器结果不一致 |
| 是否有抽样校验环节 | 检查后置逻辑 | 问题流入交付环节 |
最后补一个我踩过的小坑:AlignmentPoint的赋值如果和当前值在浮点意义上“几乎相等”,某些版本的实现会跳过内部更新,导致后续AdjustAlignment用的是缓存的旧状态。稳妥做法是在赋值前用全局容差比较一次,相等就跳过整个对齐流程,别做无效操作。这个逻辑看着多余,但在反复运行同一脚本的场景里能避免数值逐渐漂移——我见过同一个文件跑十遍对齐脚本,文字位置累计偏移了 0.3 个单位,起因就是这种浮点上的反复累加。