Как я парсил схемы Visio
Привет, Хабр! Меня зовут Алексей Грохотов, я разрабатываю продукт Сфера.Архитектура в ИТ‑холдинге Т1. Перед нашей командой стояла задача перенести документы из Orbus iServer в Сфера.Архитектуру. Iserver — это набор инструментов для описания, поддержки и трансформации архитектуры предприятия. Он в значительной степени интегрирован с Microsoft Office, например, все схемы в этом инструментарии создаются в Visio.
Я должен был проанализировать схемы Visio и извлечь необходимую информацию из этих документов. Объекты, соответствующие «прямоугольничкам и стрелочкам» Visio, уже хранились у нас в базе. Мне нужно было соотнести их с фигурами и стрелками схемы, записать для этих объектов геометрическое и текстовое содержание фигур, а также некоторые их специфические свойства. Ещё нужно было определить порты — «стыковочные места» по периметрам фигур, к которым присоединяются стрелки, а также найти надписи у стрелок и фигур. И после этого сохранить в базу данных всю найденную информацию.
Забегая вперёд, покажу результат успешного переноса в Сфера.Архитектуру схемы, нарисованной в Visio.
До:
После:
Первые подходы
В первую очередь, я поискал в интернете имеющиеся решения. Нашёл Apache POI и Aspose.Diagram. POI хорошо работал с файлами Excel, но крайне ограниченно — с документами Visio: мне удалось извлечь только название схемы, имя создавшего и некоторые другие, не особо полезные данные.
Aspose — платное решение, предоставляющее API для создания, парсинга и преобразования документов Visio в собственные форматы приложений. Он нам не подошёл, во‑первых, из‑за того, что он платный, а во‑вторых, не хотелось привязываться к стороннему ПО. К слову, у этого продукта есть очень хороший форум, на котором служба поддержки отвечает на вопросы пользователей. Эта информация очень помогла мне, когда я пытался в массе одинаковых XML‑тэгов найти ответ «как же это здесь реализовано».
Тогда я поискал по Хабру и с удивлением обнаружил, что статей про парсинг схем Visio нет. Видимо, до недавнего времени никто не занимался такой бесполезной ерундой уникальной задачей. Отчасти это, но в большей степени затраченное время заставило меня описать свои действия и результат. Может, этот опыт поможет кому-то сэкономить своё время.
Структура документа Visio
Ранние версии Visio сохраняли схемы в формате VSD. Это бинарный формат, таких документов у нас было мало, и с ними я не работал. Сейчас же используется формат VSDX, который, как и другие файлы Microsoft Office, представляет собой ZIP‑архив. В нём содержатся XML‑файлы, описывающие содержимое документа.
Для статьи я создал небольшой документ example.vsdx, представляющий собой организационную схему с гендиром, руководителем и тремя разработчиками.
Разархивируем его, чтобы посмотреть структуру файлов:
\example\docProps\app.xml
\example\docProps\core.xml
\example\docProps\custom.xml
\example\docProps\thumbnail.emf
\example\visio\document.xml
\example\visio\masters\master1.xml
\example\visio\masters\master10.xml
\example\visio\masters\master11.xml
\example\visio\masters\master12.xml
\example\visio\masters\master13.xml
\example\visio\masters\master14.xml
\example\visio\masters\master15.xml
\example\visio\masters\master2.xml
\example\visio\masters\master3.xml
\example\visio\masters\master4.xml
\example\visio\masters\master5.xml
\example\visio\masters\master6.xml
\example\visio\masters\master7.xml
\example\visio\masters\master8.xml
\example\visio\masters\master9.xml
\example\visio\masters\masters.xml
\example\visio\masters\_rels\master1.xml.rels
\example\visio\masters\_rels\master2.xml.rels
\example\visio\masters\_rels\master3.xml.rels
\example\visio\masters\_rels\master4.xml.rels
\example\visio\masters\_rels\master5.xml.rels
\example\visio\masters\_rels\master6.xml.rels
\example\visio\masters\_rels\master7.xml.rels
\example\visio\masters\_rels\masters.xml.rels
\example\visio\media\image1.jpeg
\example\visio\media\image2.bmp
\example\visio\pages\page1.xml
\example\visio\pages\pages.xml
\example\visio\pages\_rels\page1.xml.rels
\example\visio\pages\_rels\pages.xml.rels
\example\visio\solutions\solution1.xml
\example\visio\solutions\solutions.xml
\example\visio\solutions\_rels\solutions.xml.rels
\example\visio\theme\theme1.xml
\example\visio\windows.xml
\example\visio\_rels\document.xml.rels
\example\[Content_Types].xml
\example\_rels\.rels Нас интересует папка \example\visio\pages\. В ней есть файл pages.xml, содержащий описание страниц документа, и файл page1.xml, где описаны элементы на странице, фигуры и их связи друг с другом. Если файл Visio — многостраничный документ, то в папке pages будут содержаться файлы page1.xml, page2.xml, page3.xml и так далее.
Структура файла страницы
Рассмотрим подробнее структуру файла page1.xml. В нём есть элемент PageContents, у которого есть дочерние элементы Shapes и Connects. Shapes описывают свойства фигур и соединителей («стрелочек»), их местоположение на листе, размеры, содержащийся в них текст и прочее. В упрощенном виде структура файла выглядит так:
<?xml version='1.0' encoding='utf-8' ?>
<PageContents xmlns='http://schemas.microsoft.com/office/visio/2012/main'
xmlns:r='http://schemas.openxmlformats.org/officeDocument/2006/relationships' xml:space='preserve'>
<Shapes>
<Shape ID='31' NameU='Dynamic connector' Name='Динамический соединитель' Type='Shape' Master='15' UniqueID='{104EEDCC-FAF4-437C-B860-C89095023E2A}'>
<Cell N='TxtPinX' V='0.09842519462108615'/>
<Cell N='TxtPinY' V='-0.5270669460296633'/>
<!-- еще несколько элементов Cell -->
<Section N='Geometry' IX='0'>
<Row T='MoveTo' IX='1'>
<Cell N='X' V='0.09842519685039353'/>
</Row>
<Row T='LineTo' IX='2'>
<Cell N='X' V='0.09842519685039353'/>
<Cell N='Y' V='-1.054133857858449'/>
</Row>
<Row T='LineTo' IX='3' Del='1'/>
<Row T='LineTo' IX='4' Del='1'/>
</Section>
</Shape>
<Shape ID='32' Type='Shape' Master='15' UniqueID='{736D6637-EACE-43D3-B3F1-6087F0A60B11}'>
<!-- содержимое фигуры, опустил для краткости -->
</Shape>
</Shapes>
<Connects>
<Connect FromSheet='54' FromCell='EndX' FromPart='12' ToSheet='43' ToCell='Connections.Top.X' ToPart='100'/>
<!-- еще соединители, опустил для краткости -->
</Connects>
</PageContents>У каждой фигуры есть свои атрибуты. В первую очередь, меня интересуют ID — идентификатор фигуры внутри родительского элемента PageContents, и UniqueID — уникальный идентификатор UUID, или, как называет его Microsoft, GUID. Фигура также содержит дочерние элементы Cell, Section, Text и вложенные элементы Shapes. Элемент Section содержит элементы Row. Здесь меня интересует ячейки с именами PinX и PinY, определяющие местоположение фигуры на листе, а также элемент Text. Все размеры в фигурах Microsoft Visio указываются в дюймах, я умножал извлечённые значения на некоторый коэффициент, чтобы получить размеры в пикселях.
Описание соединителей
Соединители описывают, какие фигуры соединяются друг с другом. Рассмотрим в качестве примера два первых элемента Connect с атрибутом FromSheet, равным 54:
<Connects>
<!-- Строка показывает, что фигруа 54 (FromSheet='54') соединяется с фигурой 43 (ToSheet='43') -->
<Connect FromSheet='54' FromCell='EndX' FromPart='12' ToSheet='43' ToCell='Connections.Top.X' ToPart='100'/>
<!-- Эта строка показывает, что та же фигруа 54 также соединяется с фигурой 11 -->
<Connect FromSheet='54' FromCell='BeginX' FromPart='9' ToSheet='11' ToCell='Connections.Bottom.X' ToPart='103'/>
<Connect FromSheet='53' FromCell='EndX' FromPart='12' ToSheet='33' ToCell='Connections.Top.X' ToPart='100'/>
<Connect FromSheet='53' FromCell='BeginX' FromPart='9' ToSheet='11' ToCell='Connections.Left.X' ToPart='101'/>
<Connect FromSheet='32' FromCell='EndX' FromPart='12' ToSheet='21' ToCell='Connections.Top.X' ToPart='100'/>
<Connect FromSheet='32' FromCell='BeginX' FromPart='9' ToSheet='11' ToCell='Connections.Right.X' ToPart='102'/>
<Connect FromSheet='31' FromCell='EndX' FromPart='12' ToSheet='11' ToCell='Connections.Float.X' ToPart='104'/>
<Connect FromSheet='31' FromCell='BeginX' FromPart='9' ToSheet='1' ToCell='Connections.Bottom.X' ToPart='103'/>
</Connects>Атрибут FromSheet указывает на номер фигуры, содержащей описание соединителя. Из этого описания можно извлечь координаты соединителя (ячейки с именами PinX и PinY), а также координаты изгибов этого соединителя (секция Geometry, ряды MoveTo и, LineTo). Забегая вперёд, скажу, что мне не удалось перевести полученные значения в нужные мне единицы — стрелки‑соединители по этим значениям отображались криво, и мы не стали использовать эти координаты.
Атрибут ToSheet указывает на номера фигур, соединяемых этим соединителем‑стрелкой. В Connects есть два элемента Connect с номером 54, у первого атрибут ToSheet равен 43, у второго — 11. Получаем такую картину: фигура 54 описывает стрелку на схеме, соединяющую две фигуры с номерами 43 и 11. Это «Разработчик ПО Семён Шарпов» и «Руководитель разработки». Ниже я привёл содержимое этих фигур, вырезав некоторые элементы, которые я не использовал. Из этих фигур я беру информацию о содержимом фигуры (элемент Text), её размерах (ячейки Height и Width, в этом примере отсутствуют) и о расположении на схеме (ячейки PinX и PinY). Также из них можно извлечь информацию о расположении текста относительно страницы (TxtPinX и TxtPinY) и фигуры (TxtLocPinX и TxtLocPinY).
Описание фигуры
Вот элемент с номером 54, описывающий соединитель:
<Shape ID='54' NameU='Dynamic connector.54' Name='Динамический соединитель.54' Type='Shape' Master='15' UniqueID='{63A888AC-0A5D-4E4B-9738-168F9973DA8D}'>
<! -- 'PinX', 'PinY' - координаты центра фигуры-стрелки -->
<Cell N='PinX' V='6.628444882' F='Inh'/>
<Cell N='PinY' V='4.21579724416911' F='Inh'/>
<! -- 'BeginX', 'BeginY', 'EndX', 'EndY' - координаты начала и конца стрелки -->
<Cell N='BeginX' V='6.628444882' F='PAR(PNT(Sheet.11!Connections.Bottom.X,Sheet.11!Connections.Bottom.Y))'/>
<Cell N='BeginY' V='4.927657480141551' F='PAR(PNT(Sheet.11!Connections.Bottom.X,Sheet.11!Connections.Bottom.Y))'/>
<Cell N='EndX' V='6.628444882' F='PAR(PNT(Sheet.43!Connections.Top.X,Sheet.43!Connections.Top.Y))'/>
<Cell N='EndY' V='3.50393700819667' F='PAR(PNT(Sheet.43!Connections.Top.X,Sheet.43!Connections.Top.Y))'/>
<! -- PinX, PinY - координаты центра фигуры-стрелки. MoveTo, LineTo – координаты изгибов -->
<Section N='Geometry' IX='0'>
<Row T='MoveTo' IX='1'>
<Cell N='X' V='-0.09842519685039353'/>
</Row>
<Row T='LineTo' IX='2'>
<Cell N='X' V='-0.09842519685039441'/>
<Cell N='Y' V='-1.423720471944882'/>
</Row>
</Section>
</Shape>Элемент 43, описывающий фигуру «Разработчик ПО»:
<Shape ID='43' NameU='Position Belt.43' Name='Пояс должности.43' Type='Group' Master='5' UniqueID='{670A7028-8CC9-4BD9-9531-9AA1200B6158}'>
<! -- 'PinX', 'PinY' - координаты центра фигуры «Разработчик ПО» -->
<Cell N='PinX' V='6.628444882' F='PNTX(LOCTOPAR(User.PageLoc,ThePage!PageWidth,Width))'/>
<Cell N='PinY' V='3.06643700819667' F='PNTY(LOCTOPAR(User.PageLoc,ThePage!PageWidth,Width))'/>
<Text>
<cp IX='0'/>
Разработчик ПО
</Text>
<Shapes>
<Shape ID='44' Type='Group' MasterShape='6' UniqueID='{E8AEAC34-763B-41E8-8BBE-C1FCEE857515}'>
<! -- 'Height ' - высота фигуры -->
<Cell N='Height' V='0.1333828247070313' F='Inh'/>
<! -- 'TxtPinY' - координата текстового блока по оси Y -->
<Cell N='TxtPinY' V='0.06669141235351563' F='Inh'/>
<Cell N='TxtHeight' V='0.1333828247070313' F='Inh'/>
<! -- текстовый блок с содержимым -->
<Text>
<cp IX='0'/>
Семен Шарпов
</Text>
</Shape>
</Shapes>
</Shape>Элемент 11, описывающий фигуру «Руководитель разработки»:
<Shape ID='11' NameU='Manager Belt' Name='Лента менеджера' Type='Group' Master='4' UniqueID='{E4A2BCB3-9C78-425F-8D4B-C6C4654D0F6E}'>
<Cell N='PinX' V='6.628444882' F='PNTX(LOCTOPAR(User.PageLoc,ThePage!PageWidth,Width))'/>
<Cell N='PinY' V='5.365157480141551' F='PNTY(LOCTOPAR(User.PageLoc,ThePage!PageWidth,Width))'/>
<Text>
<cp IX='0'/>
Руководитель разработки
</Text>
<Shapes>
<Shape ID='12' Type='Group' MasterShape='6' UniqueID='{87F8D211-514F-4119-8F90-05FC62DD1193}'>
<Cell N='Height' V='0.1333828247070313' F='Inh'/>
<Cell N='LocPinY' V='0.06669141235351563' F='Inh'/>
<Cell N='TxtPinY' V='0.06669141235351563' F='Inh'/>
<Cell N='TxtHeight' V='0.1333828247070313' F='Inh'/>
<Cell N='TxtLocPinY' V='0.06669141235351563' F='Inh'/>
<Text>
<cp IX='0'/>
Ольга Петрова
</Text>
</Shape>
</Shapes>
</Shape>Парсинг
Открываем ZIP-файл
Как уже говорил, файл с расширением *.vsdx представляет собой ZIP-архив. Прежде, чем начать парсинг, распакуем архив, создав временный файл. Не забудьте удалить созданный файл после работы с ним:
private File extractXmlFile(ZipInputStream zipInputStream) throws IOException {
File tempFile = File.createTempFile("temp", ".xml"); // создаем временный файл
try (FileOutputStream fileOutputStream = new FileOutputStream(tempFile)) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = zipInputStream.read(buffer)) != -1) { // читаем данные из стрима, пока не достигнем конца файла
fileOutputStream.write(buffer, 0, bytesRead);
}
}
return tempFile;
}DOM-объекты
У любого элемента в XML-документе есть свои атрибуты, такие как ID у фигур, или имена и значения (N, V) у ячеек. Создадим абстрактный класс Dom с полем attributes. Также создадим его классы-наследники, соответствующие элементам XML-документа Shape, Section, Cell, Row, элементам верхнего уровня Masters и Pages.
public abstract class Dom {
private Map<String, String> attributes
}
public class Pages extends Dom {
private List<Page> pageList;
}
public class Shape extends Dom {
private List<Section> sections;
private List<Cell> cells;
private Text text;
private List<Connect> connects;
private List<Shape> shapes;
}
public class Section extends Dom {
private List<Row> rows;
}
public class Text extends Dom {
private String contents;
private CharRowProperties cp;
}При парсинге я использовал классы для обработки XML-документов DocumentBuilderFactory и DocumentBuilder из пакета javax.xml.parsers и интерфейсы Entity, Node и NodeList из пакета org.w3c.dom, представляющие «составные части» XML-документа.
Пример парсинга
Рассмотрим для примера парсинг файла page1.xml, в нём содержится максимальное количество полезной информации о схеме.
public Dom parseXml(File xmlFile) throws ParserConfigurationException, SAXException, IOException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); // часть, общая для парсинга любого XML-файла Visio
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(xmlFile);
Element root = document.getDocumentElement();
// Здесь мы можем определить, какой файл пришел на вход в метод parseXml(), запросив имя внешнего элемента структуры.
// Под каждый XML-файл Visio я создал отдельный метод, чтобы не перебирать всевозможные элементы структуры, а использовать только те, которые имеются в этом файле.
switch (root.getNodeName()) {
case "Pages" -> {
return parsePages(root); // метод для получения данных из элемента Pages (файл pages.xml)
}
case "PageContents" -> {
return parsePageContents(root); // метод для получения данных из элемента PageContents (файлы page1.xml, page2.xml и т.д.)
}
case "Masters" -> {
return parseMasters(root); // метод для получения данных из элемента Masters (файл masters.xml)
}
}
// ...
}Так как XML-файл Visio содержит много повторяющихся структур со схожими элементами, создадим параметризированный метод, возвращающий список нужных нам элементов. Я частенько сталкивался с ситуацией, когда «здесь должен быть этот элемент вот прям железно», а его не было. Поэтому почти всё проверяю на null.
private <T extends Dom> List<T> populateElements(String tagName, Element parent, Class<T> domClass) {
List<T> domList = new ArrayList<>();
if (parent == null) {
return domList;
}
NodeList nodes = parent.getChildNodes();
for (int i = 0; i < nodes.getLength(); i++) {
try {
var element = (Element) nodes.item(i);
if (tagName.equals(element.getTagName())) { // проверяем по имени, что элемент в списке - тот, что нам нужен
T dom = domClass.getDeclaredConstructor().newInstance();
dom.setAttributes(getAttributes(element));
domList.add(dom);
}
} catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
// пишем сообщение в лог
}
}
return domList;
}Например, для получения соединителей я вызываю это метод со следующими параметрами:
populateElements(
"Connect", // Название искомого элемента
(Element) root.getElementsByTagName("Connects").item(0), // родительский элемент
Connect.class // DOM-класс, коллекцию из которых возвращает метод
);Напомню, что у элемента PageContents есть два дочерних типа элементов: фигуры и соединители. Нахожу первые с помощью нашего параметризированного populateElements(). Для нахождения вложенных фигур использую отдельный метод populateShapes().
private PageContents parsePageContents(Element root) {
// находим соединители (элементы структуры, которые описывают, как фигуры соединяются друг с другом)
var connects = populateElements(
"Connect",
(Element) root.getElementsByTagName("Connects").item(0),
Connect.class
);
// фигуры могут иметь вложенные фигуры, потому ищу вложенные через отдельный метод populateShapes()
var rootNodes = root.getChildNodes();
List<Shape> outerShapes = new ArrayList<>();
for (int i = 0; i < rootNodes.getLength(); i++) {
if ("Shapes".equals(rootNodes.item(i).getNodeName())) {
outerShapes = populateShapes((Element) rootNodes.item(i));
}
}
// возвращаю родительский элемент файла page1.xml
var pageContents = new PageContents();
pageContents.setShapes(outerShapes);
pageContents.setConnects(connects);
return pageContents;
}Подобным же образом нахожу у фигуры содержимое секций и атрибутов (getSections(), getAttributes()). Содержимое методов не привожу, действуем по тому же алгоритму: ищем элементы по тегу, итерируем по найденному, в случае совпадения имени добавляем элемент к заранее созданному списку, возвращаем список элементов.
private List<Shape> populateShapes(Element element) {
List<Shape> shapes = new ArrayList<>();
List<Shape> innerShapes = new ArrayList<>();
var nodeList = element.getChildNodes();
for (int i = 0; i < nodeList.getLength(); i++) {
var shapeElement = (Element) nodeList.item(i);
var shape = new Shape();
List<Cell> cells = populateElements("Cell", shapeElement, Cell.class);
var children = shapeElement.getChildNodes();
for (int j = 0; j < children.getLength(); j++) {
if ("Shapes".equals(children.item(j).getNodeName())) {
innerShapes = populateShapes((Element) children.item(j)); // рекурсивно нахожу вложенные шейпы
}
}
shape.setSections(getSections(shapeElement));
setShapeText(shapeElement, shape);
shape.setCells(cells);
shape.setAttributes(getAttributes(shapeElement));
shape.setShapes(innerShapes);
shapes.add(shape);
}
return shapes;
}Метод для поиска текста внутри фигуры. Я также сохранял значения свойств символов (character properties, cp), на практике они нигде нам не потребовались:
private void setShapeText(Element shapeElement, Shape shape) {
var textElement = (Element) shapeElement.getElementsByTagName("Text").item(0);
if (textElement != null) {
var text = new Text();
var contents = textElement.getTextContent();
var cpNode = textElement.getElementsByTagName("cp").item(0);
var cpElement = (Element) cpNode;
var cp = new CharRowProperties();
cp.setAttributes(getAttributes(cpElement));
text.setCp(cp);
text.setContents(contents);
shape.setText(text);
}
}Подобным же образом можно искать и сохранять другие элементы. Также можно добавить логику для работы с многостраничными документами. В нашем случае подавляющее большинство схем были одностраничными, и такую доработку я не делал.
Запись в базу данных
Для записи я использовал уже имеющийся сервис интеграции. Полученные в результате парсинга параметры сопоставлял с соответствующим DTO и с помощью клиента feign отправлял на соответствующие endpoint-ы сервиса.
Заключение
Я рассказал о своём опыте парсинга информации из схем Visio в базу данных. Подходящих готовых решений не нашёл, поэтому сделал своё: сопоставлял разные элементы с объектами в базе и извлекал связанную с ними информацию. Если хотите дополнить или покритиковать, добро пожаловать в комментарии :)