“学习永远是个痛苦的过程,需要极大的勇气才能持之以恒”
1. 动机
似乎是十年之前,在得到一本图书的扫描版pdf后,由于非常喜欢该图书,所以进行了“重新编辑”(只是留给自己看,从未散播过给他人)。该图书主要是“图文配合”,且是以图为主的一本书。因此,在编辑过程中,需要将pdf中的图片复制并粘贴到word中,并且需要根据我自己的排版调整大小。由于该图书的图片特别多,近2000多张图片的样子,在调整了100张图片后果断放弃手工调整图片大小的方式,太受罪了。于是想起使用宏来处理,完全可以选择图片后录制宏来帮我完成,但是这样也得一张张处理,还是麻烦。因此,第一次“鼓起勇气”拿起VBA这个神器来。虽然之前没有学过任何VB的内容(就是懒,不愿意学),但在搜索+尝试的基础上20分钟的样子搞定了一个VBA程序,运行后几秒就完成了剩余图片的大小调整,完美完成任务。这样,第一次体会到VBA的好处。虽然是有好处,但咱也不做编辑,so,之后再也没有用过VBA。虽然MS的OFFICE功能超强,但毕竟“学习永远是个痛苦的过程”,还是能不学就不学吧(懒是一种态度)。这里吐槽一下,有专门的“人才”跑学校来讲LATEX,说其功能如何强大,且曰Word完成不了的LATEX可以。我只想说,那是您真·不会使Word。这么好的所见即所得(WYSIWYG)工具为啥不好好用,干嘛给自己找别扭?推荐侯捷老师的书《Word排版艺术》,针对Word2003的,但是里面的内容即使2019也足够你使用了,并且还有VBA这一神器。
写了那么多,下面进入正题。
由于各种原因,经常要将某种的数据结构展示为图型,特别是图啊,树啊什么的,每次虽然拿VISIO绘制也不算太麻烦,但是毕竟还得一个一个的绘制出来,于是就想能不能编制程序,通过读取文件的形式将想绘制的图形绘制出来。虽然自己编程可以实现,但是自己编的程序,绘制出来的图形可能将会比较粗糙,要想美观,需要下一定的功夫才行,本来就是为了方便(懒)嘛。并且绘制的图形希望可以进行二次编辑和调整,如果这样的功能全都由自己实现的话,光各种Style的设置就麻烦死了,毕竟咱不专业啊。于是就想,能不能用VBA直接在VISIO中绘制,这样绘制出的图形可以只是一个基本结构,美观的话可以继续进行手工调整,这样多省事儿,于是就有了本文所涉内容。
图 1就是通过VBA在VISIO绘制的,完全符合自己的要求,想上色上色,想修改修改,完全支持二次手工加工。下文就拿它的绘制作为“砖”,抛来引“玉”。
不过,对于没有VB基础的人来说,想完成这样的绘制,也还是有点麻烦的——需要熟悉熟悉VB的文法。当然这都不是关键点,关键点是MS给出的VISIO相关的VBA文档,感觉简直就是不想让人看懂,很敷衍的样子(也可能是自己肤浅了)。另外,网上的相关资料不能说少,但是很散。因此,个人自我感觉这块砖,还是得抛的。
2. 基本概念
下面先介绍一下针对Visio进行VBA编程中所需要了解的一些基本对象和概念。说实话,弄清楚下面这些对象究竟是啥含义还真是挺费事儿。特别地,微软提供的在线文档,如果在不了解这些概念前看简直就是天书,会感觉写的很抽象,但是,在了解这些概念后就好很多,起码知道在说啥,并且会发现理解起来也比较容易了。当然,本来帮助文档应该是给不熟悉的人看的,但结果是让不熟悉的人看着一头雾水,那么本身本质上还是文档的撰写者写的不合格。别跟我抬杠,很多文档的编辑者都有这样的问题,我也不一定例外。那么来看下面的基础概念:
- Page:页面对象,承载各种绘制元素的页面,包括前景和背景页面。ActivePage,激活页面,正在绘制图新的当前页面。
- Stencil:模具,Master的一个集合,可以自己创建模具,包含自己所需的各种Master。
- Master:原件,是模具中的一个对象。
- Shape:图形,在绘画页面中绘制的对象,一般通过从Stencil中拖拽一个Master对象,并放置到绘画页面上实现创建。
- Document Stencil:文档模具,一个特殊的Stencil,它里面存放的是从Stencil中拖到绘画页面中的Shape所用的那些Master的副本。请注意这里的Master不是直接引用,是copy,但如果你创建的多个Shape是来自同一个Master,那么Document Stencil中则不重新创建新的同样的Master,除非你之前修改了那个被copy的Master。这样做有什么好处呢?好处是,如果你想修改绘制页面中的现有的由相同的Master创建的所有Shape的style,那么你只要修改Document Stencil中的这个Master就能够实现全部修改。但你如果修改的是Stencil中的Master,则当前绘画页面中之前是由拖拽这个Master实现绘制的那些Shape不会进行修改。
- Section:部分,1个Shape的Section包括多个Row和Cell,对应存储该Shape再某方面的特征属性,如文字部分的特征属性集合,线条属性集合等。Shape的每个Section的具体内容可以在点击Visio“开发工具”菜单下的“ShapeSheet”按钮后弹出的窗口中查看。每个Shape均有多个Section,每个Section中均包括若干的Row,每个Row中有若干Cell。
- Row:行,Section的组成部分,每个Section可以有一到多个Row,每一行就是一个Row。
- Cell:格子,Shape,Style,或者Row对象的属性(之一)。如,Shape的每个Section中的每个Row中包括多个Cell,也就是说其中的一个Cell表示该Shape的在某Section下的某Row中的某个属性。
具体各个概念可以见图 2、图 3和图 4中的标注。还有其他的相关概念,如Selection等,这里不介绍,在后面随着使用会具体说明。特别地,图4中的ShapeSheet对应的Shape是ActivePage中被选中的“三角形”Shape,相应的Row和Cell也是这个“三角形”的。通过这次研究Visio VBA才发现人家软件的强大,真要是自己开发满足之前所述需求的软件,那会累死的。
3. 绘制设计
本文的样例是一个链表(List),包括头结点(HD),和链表中的各结点(Ai,i∈[1,n]),见图 1所示。为了简化问题,本文的链表就是由相同的结点(Node)构成,不单设头结点的数据结构,和链表结点的数据结构,以及表示链表的List结构(没学好数据结构的请回炉重铸)。因此,首先应该定义链表结点的类型。另外,本人VB不灵,属于现学现卖,所以不要在这方面来较真儿,且以后不在声明。
Type ELEMENT_NODE_Shapes
InforShape As Visio.Shape
PointerShape As Visio.Shape
End Type
ELEMENT_NODE_Shapes是定义的链表结点对象类型,其中包括数据域InforShape和指针域PointerShape,它们都是Visio.Shape类型对象,也就是上文中的Shape类型对象。这样就能够之后在其基础上绘制出两个连续的shape(矩形)来表示链表结点,见图 5,第1给矩形为数据域,第2个矩形为指针域。
首先,是DrawList过程。DrawList为主控程序,用于绘制链表,其中CreateNode用于在指定位置创建链表结点的Shape,ConnectTwoNode用“连线”Shape连接创建的两个链表Shape。简要说明VB中Sub和Function的区别:Sub就是表示过程,无返回值;Function是有返回值的,具体请参考VB相关资料。另外,需要注意的是Visio的页面的坐标和平常熟悉的Windows坐标不太一样,Windows的坐标是左上角为<0, 0>,但是Visio中式左下角为<0, 0>。本文的Visio文档是使用美制单位(英寸),所以1个单位长度就是1英寸。PosX和PosY分别对应一个Shape对象的绘制位置,即图形外框的中心点(三角形Shape的外框也是矩形的)。
注意:在简书的代码编辑系统中,不认VB的注释,所以前面加了//,如果你copy 的话,请恢复。
Sub DrawList()
Dim Delta As Double
//' 增量,1.25个单位,本文是英寸
Delta = 1.25
Dim PosX As Double
Dim PosY As Double
PosX = 0.5 //' 图形绘制X坐标
PosY = 10 //' 图形绘制Y坐标
//' 头结点用于头结点的处理
Dim HeadNode As ELEMENT_NODE_Shapes
//' 通过CreateNode在指定的位置创建头结点,并指定头结点的数据域显示的字符串
HeadNode = CreateNode(PosX, PosY, "HD")
//' 定义结点数组
Dim Node(100) As ELEMENT_NODE_Shapes
//' PrevNode用于之后的链表连续处理
Dim PrevNode As ELEMENT_NODE_Shapes
PrevNode = HeadNode
//'连续创建10个结点,并且用ConnectTwoNode实现结点Shape的连线(带箭头的线)
For Index = 1 To 10
PosX = PosX + Delta //'横坐标每次递增Delta
//' 链表终结点Shape数据域显示的文字是A和index凑成的字符串,如A5
Node(Index - 1) = CreateNode((PosX), PosY, "A" & Index)
//' 每次将PrevNode和当前新创建的结点进行连线
Call ConnectTwoNode(PrevNode, Node(Index - 1))
PrevNode = Node(Index - 1) //' PrevNode绑定到新创建的结点上,以便后继处理
//' 图形绘制Y坐标的控制,达到一定程度就“换行”
If Index Mod 5 = 0 Then
PosX = 0.5
PosY = PosY - 1
End If
Next
End Sub
其次,是CreateNode方法,用于在start_x和start_y位置绘制创建的Node并返回创建的Node,该Node的数据域显示的是由info传入的String。其中需要说明的内容有:
- ActivePage.Drop,该方法将指定的Stencil中的Master对象绘制到页面中。
- 通过Application.Documents.Item指定想要的Stencil,如本文使用的“BASIC_M.vssx”(“基本形状”)。至于具体如何知道那个Stencil叫啥名字,本人也没有太好的办法,目前采用就是通过录制宏的形式,拖动一个想要的Stencil中的Master,来查看录制好的宏代码中的Stencil和Master的名称。特别是本人使用的是中文版的Visio。看看以后有没有啥好方法,您如果知道请不吝赐教!
- 设置一个绘制好的Shape的文字的字体大小需要访问该Shape对应的Characters的Section中的Row的存储字体配置的Cell。下面TempNode.InforShape,就是结点的数据域对应的Shape,其通过.Characters获得对应的字符Section属性对象,并通过.CharProps属性,指定visCharacterSize参数获得字符Size的属性,并设置为14pt,见图 6,红框中是该Shape的Character Section,蓝框中的是对应Size的Cell。其中visCharacterSize是一个枚举量,其值为7,你写7其实也是可以的,具体可以参考MS文档:https://docs.microsoft.com/en-us/office/vba/api/visio.characters.charprops,建议有条件的话,还是看英文的文档。另外,Cell有不同的访问方式,如:可以通过Shape对象的CellsSRC方法访问。
- 可以通过Shape.CellsSRC属性来访问一个Shape的某Section下的某Row中的某Cell,要么怎么名字中有SRC,具体可以参考MS文档:https://docs.microsoft.com/en-us/office/vba/api/visio.shape.cellssrc。每个Shape都有一个ShapeSheet,这个就相当于一个三维表,那么通过第1个参确定要访问的Section,第2个参数指定确定的Section中要访问的Row,第3个参数指定该Row中的Cell。在没有了解SheetShape的作用之前,看MS提供的文档真是要命,根本不知道他们在说啥,费事儿就费事儿在这里了。
- Section、Row、Cell的枚举量的定义,请参考MS的这3篇文档,有了它你就能够拿到Shape中的任何你想要的Cell:
a) https://docs.microsoft.com/en-us/office/vba/api/visio.vissectionindices
b) https://docs.microsoft.com/en-us/office/vba/api/visio.visrowindices
c) https://docs.microsoft.com/en-us/office/vba/api/visio.viscellindices - 获得了Cell,通过设置Formula,类似于Excel中的每个方格cell,ShapeSheet里面每个Cell都可以使用公式来完成属性的设置,这点不得不佩服MS的强大,能够左到不同工具的统一的处理(好像也就应该这么做才更合理)。
- 组合绘制好的数据域和指针域的Shape。组合是先建立选择Selection对象,然后选择各个Shape,然后调用Selection对象的Group方法将已经选定的各个图形进行组合。需要说明一下,选择第一个Shape时,需要保证没有选择其他的Shape,因此,Select方法的的选择动作参数(第2个参数)要绑定visDeselectAll 和 visSelect,表示先全不选然后选择该Shape。
Function CreateNode(start_x As Double, start_y As Double, info As String) As ELEMENT_NODE_Shapes
Dim TempNode As ELEMENT_NODE_Shapes
Set TempNode.InforShape =
//' 在当前页面Drop一个矩形到指定位置,该矩形由BASIC_M.vssx这个Stencil里面的矩形Master得到
ActivePage.Drop(Application.Documents.Item("BASIC_M.vssx").Masters.ItemU("Rectangle"), start_x, start_y)
TempNode.InforShape.Text = info //' 设置该矩形的文字信息为参数info的内容
//' 设置该Shape的字符size的属性
TempNode.InforShape.Characters.CharProps(visCharacterSize) = 14
//' 绘制指针域的Shape,横移0.5个单位长度
Set TempNode.PointerShape = ActivePage.Drop(Application.Documents.Item("BASIC_M.vssx").Masters.ItemU("Rectangle"), start_x + 0.5, start_y)
//' 声明一个Cell变量,用于之后访问不同的Cell
Dim TempCell As Visio.Cell
//' 设置绘制矩形(数据域和指针域)的透明度为0.8(80%的透明度)
Set TempCell = TempNode.InforShape.CellsSRC(visSectionObject, visRowFill, visFillForegndTrans)
TempCell.Formula = "0.8"
Set TempCell = TempNode.PointerShape.CellsSRC(visSectionObject, visRowFill, visFillForegndTrans)
TempCell.Formula = "0.8"
//' 设置绘制矩形(数据域和指针域)的宽和长,均为0.5英寸
Set TempCell = TempNode.InforShape.CellsSRC(visSectionObject, visRowXFormOut, visXFormWidth)
TempCell.Formula = "0.5"
Set TempCell = TempNode.InforShape.CellsSRC(visSectionObject, visRowXFormOut, visXFormHeight)
TempCell.Formula = "0.5"
Set TempCell = TempNode.PointerShape.CellsSRC(visSectionObject, visRowXFormOut, visXFormWidth)
TempCell.Formula = "0.5"
Set TempCell = TempNode.PointerShape.CellsSRC(visSectionObject, visRowXFormOut, visXFormHeight)
TempCell.Formula = "0.5"
//' 下面要进行组合,所以声明一个选择对象的变量
Dim Selection As Visio.Selection
//' 设置为激活窗口的选择
Set Selection = ActiveWindow.Selection
//' 选择数据域的矩形Shape,并且先全部去掉选择,然后再选择,防止选取了其他的Shape
Selection.Select TempNode.InforShape, visDeselectAll + visSelect
//' 然后再选择指针域的矩形Shape
Selection.Select TempNode.PointerShape, visSelect
//' 申明一个Shape变量,之后绑定组合后的对象
Dim GroupShape As Visio.Shape
//' 将之前选择的Shape进行组合,组合的结果绑定到GroupShape上
Set GroupShape = Selection.Group
//' 返回的是创建的TempNode,也就是链表结点结构的对象,而不是Group之后的对象,因为之后指针域的Shape需要进行连线到后一个结点的数据域对象上
CreateNode = TempNode
End Function
再次,ConnectTwoNode使用一个连接线(带箭头)连接两个Node的对应Shape,前一个Node的指针域Shape的X2连接点作为起点,后一个Node的数据域Shape的X4连接点的作为终点。其中,需要说明的有:
- 连接线,本文里面选择的是动态连接线,就是开始菜单下的“连接线”(一般在“指针工具”下方),因为其可以根据连接的两个Shape之间的相对位置会“动态”调整线条布局。也可以根据需求选择调用Shape的一些图形绘制方法去创建,如:DrawArcByThreePoints,DrawLine等。
- 动态连接线默认没有箭头,需要进行设置,通过CellsSRC方法可以获的连接线对应的属性的Cell,通过设置属性值改变该属性。
- 连接Shape,先获得连接线的起点Cell和终点Cell,将使用起点Cell和终点Cell的的GlutTo方法,分别连接到不同Shape的连接点(Connection Point)上。
- 不同类型的Shape,会有不同数量的Connection Point,如:三角形有四个,分别是三个顶点和中间的,名称分别是Connection.X1~ Connection.X4(名称缺省不显示,见红框左侧,但可以选择一个后点击其他灰色框的观察其缺省名称),具体的情况可以通过观察该Shape的SheetShape进行确定,见图 7所示。
//' 使用动态连线,连接2个链表结点Node中的指针域和数据域的Shape
Sub ConnectTwoNode(PrevNode As ELEMENT_NODE_Shapes, NextNode As ELEMENT_NODE_Shapes)
//' 声明并获取“动态连线”的Shape
Dim Line As Visio.Shape
Set Line = ActivePage.Drop(ActiveDocument.Masters.ItemU("Dynamic connector"), 1, 1)
//' 由于缺省的连线不带箭头,因此需要在连线的末位位置设置箭头,同样通过CellsSRC获取对应的Cell
Dim ArrowOfLine As Visio.Cell
//' 设置箭头的类型
Set ArrowOfLine = Line.CellsSRC(visSectionObject, visRowLine, visLineEndArrow)
ArrowOfLine.Formula = "5"
//' 设置箭头的大小
Set ArrowOfLine = Line.CellsSRC(visSectionObject, visRowLine, visLineEndArrowSize)
ArrowOfLine.Formula = "2"
Dim LineBeginX As Visio.Cell
Dim LineEndX As Visio.Cell
//' 获得连线的两端的Cell
Set LineBeginX = Line.Cells("BeginX")
Set LineEndX = Line.Cells("EndX")
//' 通过连接线的名字获取前面Node的指针域和后面Node的数据域对应的连接点,然后调用连接线两端Cell的GlueTo操作进行连接,由于从前向后,因此分别时X2和X4
Dim CellGlueToRect01 As Visio.Cell
Set CellGlueToRect01 = PrevNode.PointerShape.Cells("Connections.X2")
Dim CellGlueToRect02 As Visio.Cell
Set CellGlueToRect02 = NextNode.InforShape.Cells("Connections.X4")
LineBeginX.GlueTo CellGlueToRect01
LineEndX.GlueTo CellGlueToRect02
End Sub
最后,运行该VBA程序,就可以在Visio的当前页面中绘制出如图 1 所示的链表。
本文主要做抛砖引玉之用,通过提供一个完整的样例来展示如何通过VBA编程实现在Visio中进行图形的绘制。通过VBA可以方便的操作各种图形,有兴趣的读者可以实现相关的函数库,方便自己组合使用。