Thursday, October 17, 2024

A .NET API implementation of Express Tool's Text Explode Command (TXTEXP)

I have not done a blog article for quite long, for various reasons (one of them being a bit lazier😆). The code project in this article was mostly quite a while ago and I finally arranged a bit of time to sorted it clean for posting.

AutoCAD's Express Tool suite comes with LISP-defined command "TXTEXP", which can explode TEXT entity in drawing into a bunch of curve entities. 

As AutoCAD programmers, we may want to explode TEXT entity, as the "TXTEXP" does, in our coded process. Surely, we can call the LISP-defined command in our code, most likely, using SendStringToExecute()/SendCommand() method. However, is may cases, in our coded process, we not only want to the target TEXT entity being exploded into curve entities, but also want to hold onto the explosion-generated curve entities for further operation. 

In the past I have see a few threads in the AutoCAD programming discussion forum about this issue, such as this , and this.

However, since the "TXTEXP" is a LISP-defined command, calling it with SendStringToExecute()/SendCommand() make it being executed "asynchronously", which makes it difficult for our coded process to wait for the exploding to be completed and then collect the newly generated curve entities and then continue.

So, I decided to implement a .NET API version of "TXTEXP" operation, which can be used in a CommandMethod to do the same as the command "TXTEXP", or it can be used in a custom .NET API process synchronously. To do so, I studied the LISP routine, comes with Express Tool suite "txtexp.lsp" (in the folder "C:\Program Files\Autodesk\AutoCAD 20xx\Express\").

Here are the steps of how to explode TEXT entity into curve entities:

1. Select/identify a target TEXT entity in current drawing;

2. Export the selected TEXT entity to a WMF (Windows metafile, a image file format that can have both vector and bitmap graphic contents) with AutoCAD's COM API: AcadDocument.Export(). Since the Export() method requires an AcadSelectionSet as input, thus in the step 1, the target TEXT entity must be added into a COM API's AcadSelectionSet;

3. Import the WMF file into AutoCAD with COM API AcadDocument.Import(), which will create a block definition with name prefixed with "WMF" and a block reference at supplied location.

4. Explode the block reference, which would result in a bunch of curve entities (line, arc, circle, polyline) that form the original text visually.

So, the actually conversion of a TEXT entity to individual curves actually happens when the TEXT entity is VISUALLY exported into WMF file.

Based on these 4 steps, I quickly put together some code and yes, it works expected, well, if the TEXT entity is based on AutoCAD native, shape-based fonts. If the TEXT entity uses True-Type font, then it did not work. It turns out, for True-Type font, the WMF export only works (to convert TEXT entity to WFM image) if the TEXT entity is mirrored backwardly. If scanning the LISP code of "TXTEXP.lsp", one would see "MIRROR" command is called before and after WMF is exported and imported. So, I added code the mirror the target TEXT entity before exporting and mirror the backward text image block back after importing. The actually transformation is a bit more complicated than just mirroring, because the TEXT entity could be in different rotation angle. So, I first rotate it to horizontal, and mirror it. After importing, the apply the combined transformation inversely back.

Another factor to be considered is that the target TEXT entity could be in a crowded drawing with other noisy entities overlapping (at its rotated and mirrored position). So before exporting, I make a window selection with the window being the target TEXT entity (again, rotated and mirrored, so it is horizontal TEXT entity), and thus any selected entity to invisible (Entity.Visible = false), and turn them back visible after importing.

If the target text entity is a MTEXT, then simply explode the MTEXT into one or multiple TEXT entities and do the exploding process against each TEXT entity.

Following are the codes of the class TextExploder and a CommandClass showing how to use it:

Class TextExploer

using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using CadApp = Autodesk.AutoCAD.ApplicationServices.Application;
 
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using Autodesk.AutoCAD.Colors;
 
namespace ExplodeText
{
    public class TextExploder
    {
        private Document _dwg = null;
        private Database _db = null;
        private Editor _ed = null;
 
        private Extents3d _txtExtents = new Extents3d();
        private List<string> _existingWmfBlockNames;
        private string _layer = null;
        private double _rotation = 0;
 
        public TextExploder(Document dwg, string explodeToLayer=null)
        {
            _dwg= dwg;
            _db = _dwg.Database;
            _ed = _dwg.Editor;
            _layer= explodeToLayer;
 
            if (!string.IsNullOrEmpty(_layer) && !LayerExists(_layer))
            {
                throw new ArgumentException(
                    $"Designate layer does not exist: {_layer}");
            }
        }
 
        public List<ObjectId> ExplodeMText(ObjectId mtextEntId)
        {
            if (mtextEntId.ObjectClass.DxfName.ToUpper() != "MTEXT")
            {
                throw new ArgumentException("No MText entity.");
            }
 
            var resultCurves = new List<ObjectId>();
 
            var txtIds = ExplodeMTextToDbTexts(mtextEntId);
 
            foreach (var txtId in txtIds)
            {
                var curveIds = DoDBTextExplode(txtId);
                if (curveIds!=null)
                {
                    resultCurves.AddRange(curveIds);
                }
            }
 
            return resultCurves;
        }
 
        public List<ObjectId> ExplodeDBText(ObjectId txtEntId)
        {
            if (txtEntId.ObjectClass.DxfName.ToUpper() != "TEXT")
            {
                throw new ArgumentException("No DBText entity.");
            }
 
            var curveIds = DoDBTextExplode(txtEntId);
            return curveIds;
        }
 
        #region private methods: misc
 
        private List<ObjectId> DoDBTextExplode(ObjectId txtEntId)
        {
            List<ObjectId> curveIds = null;
 
            string wmfFileName = null;
            string wmfFilePath = null;
 
            // generate WMF image file
            if (!ExportWmfImage(txtEntId, 
                out wmfFileName, out wmfFilePath, 
                out string layer, out _rotation)) return null;
 
            if (string.IsNullOrEmpty(_layer))
            {
                _layer = layer;
            }
 
            // import the WMF image as block
            var importFile = $"{wmfFilePath}{wmfFileName}.wmf";
            try
            {
                if (!ImportWmfImage(importFile, _txtExtents.MinPoint))
                {
                    return null;
                }
            }
            finally
            {
                if (File.Exists(importFile)) File.Delete(importFile);
            }
 
            // process WMF image block
            _existingWmfBlockNames = GetExistingWmfBlockNames();
            curveIds = PostWmfImportWork();
 
            // erase original text
            if (curveIds != null)
            {
                EraseTextEntity(txtEntId);
            }
 
            return curveIds;
        }
 
        private List<ObjectId> ExplodeMTextToDbTexts(ObjectId mtextId)
        {
            var textIds = new List<ObjectId>();
 
            using (var tran = _db.TransactionManager.StartTransaction())
            {
                var mtext = (MText)tran.GetObject(mtextId, OpenMode.ForWrite);
                var dbObjects = new DBObjectCollection();
                mtext.Explode(dbObjects);
                var space=(BlockTableRecord)tran.GetObject(mtext.BlockId, OpenMode.ForWrite);
                foreach (DBObject dbObject in dbObjects)
                {
                    if (dbObject is DBText)
                    {
                        var id = space.AppendEntity(dbObject as DBText);
                        tran.AddNewlyCreatedDBObject(dbObject, true);
                        textIds.Add(id);
                    }
                    else
                    {
                        dbObject.Dispose();
                    }
                }
                mtext.Erase();
                tran.Commit();
            }
 
            return textIds;
        }
 
        private void EraseTextEntity(ObjectId entId)
        {
            using (var tran = _db.TransactionManager.StartTransaction())
            {
                var txt = (DBText)tran.GetObject(entId, OpenMode.ForWrite);
                txt.Erase();
                tran.Commit();
            }
        }
 
        #endregion
 
        #region private methods: epxort DBText as WMF image
 
        private bool PrepareTextForExport(
            ObjectId txtEntId, 
            out Extents3d origExt,
            out Extents3d zoomExt,
            out string layer, 
            out double rotation)
        {
            origExt = new Extents3d();
            zoomExt = new Extents3d();
            layer = "";
            rotation = 0.0;
 
            var hasText = true;
            using (var tran=_db.TransactionManager.StartOpenCloseTransaction())
            {
                var txt = (DBText)tran.GetObject(txtEntId, OpenMode.ForWrite);
                if (txt.TextString.Trim().Length > 0)
                {
                    rotation = txt.Rotation;
                    origExt = txt.GeometricExtents;
                    layer = txt.Layer;
 
                    // rotate the text to horizontal
                    txt.TransformBy(
                        Matrix3d.Rotation(-txt.Rotation, Vector3d.ZAxis, txt.Position));
 
                    // mirror the text in order for TrueFont text to be converted to curves
                    using (var mirrorLine = new Line3d())
                    {
                        mirrorLine.Set(
                            txt.Position, 
                            new Point3d(txt.Position.X, txt.Position.Y+1000, txt.Position.Z));
                        txt.TransformBy(Matrix3d.Mirroring(mirrorLine));
                    }
 
                    zoomExt = txt.GeometricExtents; 
                }
                else
                {
                    _ed.WriteMessage("\nText entity has empty text string!");
                    hasText = false;
                }
                
                tran.Commit();
            }
            return hasText;
        }
 
        private bool LayerExists(string layer)
        {
            var exists = false;
            using (var tran = _db.TransactionManager.StartOpenCloseTransaction())
            {
                var layerTable = (LayerTable)tran.GetObject(
                    _db.LayerTableId, OpenMode.ForRead);
                exists = layerTable.Has(layer);
                tran.Commit();
            }
            return exists;
        }
 
        private bool ExportWmfImage(
            ObjectId txtId, 
            out string wmfFileName, out string wmfFilePath, 
            out string layer, out double rotation)
        {
            var exportOk = true;
 
            wmfFileName = string.Empty;
            wmfFilePath = string.Empty;
 
            var mirrText = (short)CadApp.GetSystemVariable("MIRRTEXT");
            try
            {
                CadApp.SetSystemVariable("MIRRTEXT", 1);
 
                exportOk = PrepareTextForExport(
                    txtId, out _txtExtents, out Extents3d zoomExt, out layer, out rotation);
                if (exportOk)
                {
                    using (var tempView = new WmfZoomedView(_ed, zoomExt))
                    {
                        CadApp.UpdateScreen();
 
                        dynamic preferences = CadApp.Preferences;
                        wmfFilePath = preferences.Files.TempFilePath;
                        wmfFileName = GetWmfFileName(txtId);
                        exportOk = ExportTextEntity(txtId, zoomExt, wmfFileName, wmfFilePath);
                    }
                }
            }
            finally
            {
                CadApp.SetSystemVariable("MIRRTEXT", mirrText);
            }
            return exportOk;
        }
 
        private string GetWmfFileName(ObjectId id)
        {
            var name = id.Handle.ToString();
            var i = 1;
            var fileName = "";
            while(true)
            {
                fileName = $"{name}_{i}";
                if (System.IO.File.Exists(fileName))
                {
                    i++;
                }
                else
                {
                    return fileName;
                }
            }
        }
 
        private bool ExportTextEntity(
            ObjectId txtId, Extents3d zoomExtents, string wmfFile, string wmfFilePath)
        {
            var ok = true;
            dynamic comDoc = _dwg.GetAcadDocument();
            dynamic ss = comDoc.SelectionSets.Add(txtId.Handle.ToString());
            var hiddenEnts = new List<ObjectId>();
            try
            {
                var pt1 = new double[]
                { 
                    zoomExtents.MinPoint.X, 
                    zoomExtents.MinPoint.Y, 
                    zoomExtents.MinPoint.Z 
                };
                var pt2 = new double[] 
                { 
                    zoomExtents.MaxPoint.X, 
                    zoomExtents.MaxPoint.Y, 
                    zoomExtents.MaxPoint.Z 
                };
                ss.Select(0, pt1, pt2);
                
                var count = ss.Count;
                if (count>1)
                {
                    hiddenEnts = HideExtraEntitiesInSelectionSet(ss);
                }
 
                var fName = $"{wmfFilePath}{wmfFile}.wmf";
                if (File.Exists(fName)) File.Delete(fName);
                comDoc.Export($"{wmfFilePath}{wmfFile}""wmf", ss);
            }
            catch(System.Exception ex)
            {
                _ed.WriteMessage(
                    $"\nExporting WMF file error:\n{ex.Message}");
                ok = false;
            }
            finally
            {
                ss.Delete();
                if (hiddenEnts.Count>0)
                {
                    TurnOffEntities(hiddenEnts, true);
                }
            }
 
            return ok;
        }
 
        private List<ObjectId> HideExtraEntitiesInSelectionSet(dynamic selectionSet)
        {
            var ids=new List<ObjectId>();
            foreach (dynamic ent in selectionSet)
            {
                string handleString = ent.Handle;
                var handle=new Handle(
                    long.Parse(handleString, System.Globalization.NumberStyles.HexNumber));
                if (_db.TryGetObjectId(handle, out ObjectId id))
                {
                    var dxfName = id.ObjectClass.DxfName.ToUpper();
                    if (dxfName!="TEXT" && dxfName!="MTEXT")
                    {
                        ids.Add(id);
                    }
                }
            }
 
            if (ids.Count>0)
            {
                TurnOffEntities(ids, false);
            }
 
            return ids;
        }
 
        private void TurnOffEntities(List<ObjectId> entityIds, bool visible)
        {
            using (var tran = _db.TransactionManager.StartTransaction())
            {
                foreach (var id in entityIds)
                {
                    var ent = (Entity)tran.GetObject(id, OpenMode.ForWrite);
                    ent.Visible = visible;
                }
                tran.Commit();
            }
        }
 
        #endregion
 
        #region private methods: import WMF image
 
        private List<string> GetExistingWmfBlockNames()
        {
            List<string> names;
            using (var tran = _db.TransactionManager.StartTransaction())
            {
                var blkTable = (BlockTable)tran.GetObject(
                    _db.BlockTableId, OpenMode.ForRead);
                names = blkTable.Cast<ObjectId>()
                    .Select(id => ((BlockTableRecord)tran.GetObject(id, OpenMode.ForRead)).Name)
                    .Where(name => name.ToUpper().StartsWith("WMF")).ToList();
                tran.Commit();
            }
            return names;
        }
 
        private bool ImportWmfImage(string wmfFile, Point3d insPt)
        {
            try
            {
                var pt = new double[] { insPt.X, insPt.Y, insPt.Z };
                dynamic comDoc = _dwg.GetAcadDocument();
                comDoc.Import(wmfFile, pt, 1.0);
                return true;
            }
            catch(System.Exception ex)
            {
                _ed.WriteMessage($"\nImportiing WMF image error:\n{ex.Message}");
                return false;
            }
        }
 
        #endregion
 
        #region private methods: Convert imported WMF block into curves
 
        private List<ObjectId> PostWmfImportWork()
        {
            List<ObjectId> curveIds = null;
            using (var tran = _db.TransactionManager.StartTransaction())
            {
                var space=(BlockTableRecord)tran.GetObject(
                    _db.CurrentSpaceId, OpenMode.ForWrite);
                var wmfBlkRef = FindWmfBlock(space, tran);
                if (wmfBlkRef!=null)
                {
                    wmfBlkRef.UpgradeOpen();        
                    curveIds = ConvertWmfBlockReferenceToCurves(wmfBlkRef, space, tran);
                    if (curveIds.Count>0)
                    {
                        try
                        {
                            wmfBlkRef.Erase();
 
                            var wmfBlkDef = (BlockTableRecord)tran.GetObject(
                                wmfBlkRef.BlockTableRecord, OpenMode.ForWrite);
                            wmfBlkDef.Erase();
                        }
                        catch { }
                    }
                }
                else
                {
                    _ed.WriteMessage(
                        $"\nCannot find imported WMF image block reference.");
                }
 
                tran.Commit();
            }
 
            return curveIds;
        }
 
        private BlockReference FindWmfBlock(BlockTableRecord space, Transaction tran)
        {
            var blks = new List<BlockReference>();
            foreach (ObjectId id in space)
            {
                if (id.ObjectClass.DxfName.ToUpper() != "INSERT"continue;
                var blk = (BlockReference)tran.GetObject(id, OpenMode.ForRead);
                if (blk.Name.ToUpper().StartsWith("WMF"))
                {
                    blks.Add(blk);
                }
            }
 
            if (blks.Count>0)
            {
                return (from b in blks 
                        orderby b.ObjectId.Handle.Value descending 
                        select b).First();
            }
            else
            {
                return null;
            }
        }
 
        private BlockTableRecord FindImportedWmfBlock(Transaction tran)
        {
            var blkTable = (BlockTable)tran.GetObject(
                _db.BlockTableId, OpenMode.ForRead);
            foreach (ObjectId blkId in blkTable)
            {
                var blk = (BlockTableRecord)tran.GetObject(
                    blkId, OpenMode.ForRead);
                if (blk.Name.ToUpper().StartsWith("WMF"))
                {
                    if (IsImportedWmfBlock(blk.Name)) return blk;
                }
            }
            return null;
        }
 
        private bool IsImportedWmfBlock(string blkName)
        {
            foreach (var name in _existingWmfBlockNames)
            {
                if (name.ToUpper() == blkName.ToUpper()) return false;
            }
            return true;
        }
 
        private List<ObjectId> ConvertWmfBlockReferenceToCurves(
            BlockReference blkRef, BlockTableRecord space, Transaction tran)
        {
            // mirror and rotate the block reference before exploding it
            using (var mirrorLine = new Line3d())
            {
                mirrorLine.Set(
                    blkRef.Position, 
                    new Point3d(blkRef.Position.X, blkRef.Position.Y + 1000, blkRef.Position.Z));
                blkRef.TransformBy(Matrix3d.Mirroring(mirrorLine));
            }
            blkRef.TransformBy(Matrix3d.Rotation(_rotation, Vector3d.ZAxis, blkRef.Position));
 
            // explode the block reference
            var ents = ExplodeWmfBlock(blkRef, tran);
 
            var wmfExtents = GetWmfExtents(ents);
            var scale = 
                (_txtExtents.MaxPoint.X - _txtExtents.MinPoint.X) / 
                (wmfExtents.MaxPoint.X - wmfExtents.MinPoint.X);
            var mtScale = Matrix3d.Scaling(scale, wmfExtents.MinPoint);
            var mtMove = Matrix3d.Displacement(
                wmfExtents.MinPoint.GetVectorTo(_txtExtents.MinPoint));
            
            var curveIds=new List<ObjectId>();
 
            foreach (var ent in ents)
            {
                if (!string.IsNullOrEmpty(_layer))
                {
                    ent.Layer = _layer;
                    ent.Color = Color.FromColorIndex(ColorMethod.ByLayer, 256);
                }
                ent.TransformBy(mtScale);
                ent.TransformBy(mtMove);
 
                var curveId = space.AppendEntity(ent);
                tran.AddNewlyCreatedDBObject(ent, true);
 
                curveIds.Add(curveId);
            }
 
            return curveIds;
        }
 
        private List<Entity> ExplodeWmfBlock(BlockReference blkRef, Transaction tran)
        {
            var ents=new List<Entity>();
            using (var objs = new DBObjectCollection())
            {
                blkRef.Explode(objs);
                foreach (DBObject obj in objs)
                {
                    var ent = (Entity)obj;
                    
                    ents.Add(ent);
                }
            }
            return ents;
        }
 
        private Extents3d GetWmfExtents(List<Entity> ents)
        {
            var ext = new Extents3d();
            foreach (var ent in ents)
            {
                ext.AddExtents(ent.GeometricExtents);
            }
            return ext;
        }
        #endregion
    }
}

Here is a helper class WmfZoomedView:

using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using System;
 
namespace ExplodeText
{
    public class WmfZoomedView : IDisposable
    {
        private readonly Editor _ed;
 
        private ViewTableRecord _currentView = null; 
 
        public WmfZoomedView(Editor ed, Extents3d zoomExtents)
        {
            _ed = ed;
            _currentView = _ed.GetCurrentView();
            ZoomToExtents(zoomExtents);
        }
 
        public void Dispose()
        {
            if (_currentView != null)
            {
                _ed.SetCurrentView(_currentView);
            }
        }
 
        private void ZoomToExtents(Extents3d zoomExtents)
        {
            var d = (zoomExtents.MaxPoint.X - zoomExtents.MinPoint.X) / 50;
 
            var pt1 = new[] 
            { 
                zoomExtents.MinPoint.X - d, 
                zoomExtents.MinPoint.Y - d, 
                zoomExtents.MinPoint.Z 
            };
            var pt2 = new[] 
            { 
                zoomExtents.MaxPoint.X + d, 
                zoomExtents.MaxPoint.Y + d, 
                zoomExtents.MaxPoint.Z 
            };
 
            dynamic comApp = Application.AcadApplication;
            comApp.ZoomWindow(pt1, pt2);
        }
 
    }
}

Here is the CommandClass/Method that drives the actual text exploding operation:

using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Runtime;
using System.Collections.Generic;
using CadApp = Autodesk.AutoCAD.ApplicationServices.Application;
 
[assemblyCommandClass(typeof(ExplodeText.MyCommands))]
 
namespace ExplodeText
{
    public class MyCommands 
    {
        [CommandMethod("BlowUpText")]
        public static void RunMyCommand()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var editor = dwg.Editor;
 
            var txtId = SelectText(editor);
            if (!txtId.IsNull)
            {
                List<ObjectId> explodedCurves;
                var exploder = new TextExploder(dwg);
                if (txtId.ObjectClass.DxfName.ToUpper() == "TEXT")
                {
                    explodedCurves = exploder.ExplodeDBText(txtId);
                }
                else
                {
                    explodedCurves = exploder.ExplodeMText(txtId);
                }
 
                // Do something with explosion-generated curves
                editor.WriteMessage(
                    $"\n{explodedCurves.Count} curves generated from text explodion.\n");
            }
            else
            {
                editor.WriteMessage("\n*Cancel*");
            }
            editor.PostCommandPrompt();
        }
 
        private static ObjectId SelectText(Editor ed)
        {
            var opt = new PromptEntityOptions(
                "\nSelect a TEXT/MTEXT entity:");
            opt.SetRejectMessage("\nInvalid: must be a TEXT/MTEXT entity.");
            opt.AddAllowedClass(typeof(DBText), true);
            opt.AddAllowedClass(typeof(MText), true);
            var res = ed.GetEntity(opt);
            if (res.Status == PromptStatus.OK)
            {
                return res.ObjectId;
            }
            else
            {
                return ObjectId.Null;
            }
        }
    }
}


See following video showing the code in action:


If one feels difficult to read all the code posted here and more interested in to actually run/debug the code in detail, the entire Visual Studio solution can be accessed from GitHub here:

https://github.com/norman-yuan/ExplodeText.git

I am not sure/do not know if somebodies have done similar .NET API equivalents of the Express Tool's TXTEXP command. This could be served as an inspiration for someone to try to implement his/her own version of it while learning AutoCAD .NET API programming.