Привет, Хабр! Меня зовут Алексей Грохотов, я разрабатываю продукт Сфера.Архитектура в ИТ‑холдинге Т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 в базу данных. Подходящих готовых решений не нашёл, поэтому сделал своё: сопоставлял разные элементы с объектами в базе и извлекал связанную с ними информацию. Если хотите дополнить или покритиковать, добро пожаловать в комментарии :)
