CAD二次开发DBText对齐方式与批量居中实战

发布时间:2026/11/6 4:35:38
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枚举,作用是把“水平方式 + 垂直方式”这个二维组合压扁成一个单值,方便你读取当前状态,仅此而已。

真正可写的是两个正交属性:

属性枚举类型可取值说明
HorizontalModeTextHorizontalModeTextLeft/TextCenter/TextRight/TextAlign/TextMid/TextFit控制水平锚定
VerticalModeTextVerticalModeTextBase/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 个单位,起因就是这种浮点上的反复累加。