[Из песочницы] Конвертация текстовых документов в xml на С#

Недавно мне пришлось столкнуться с необходимостью достать текст из офисных документов (docx, xlsx, rtf, doc, xls, odt и ods). Задача осложнялась требованием представить текст в формате xml без мусора с максимально удобной для дальнейшего парсинга структурой.

Решение использовать Interop сразу отпало по причине его громоздкости, во многом избыточности, а также необходимости устанавливать на сервер MS Office. В результате, решение было найдено и воплощено на внутреннем проекте. Однако, поиск оказался настолько сложен и не тривиален в силу отсутствия каких-либо общедоступных мануалов, что мной было принято решение написать в свободное от работы время библиотеку, которая решала бы указанную задачу, а также создать написать что-то вроде инструкции, чтобы разработчики прочитав ее смогли, хотя бы поверхностно, разобраться в вопросе.

Прежде, чем перейти к описанию найденного решения, предлагаю ознакомиться с некоторыми выводами, которые были сделаны в результате моих изысканий:


  1. Для платформы .Net не существует какого-либо готового решения для работы со всеми перечисленными форматами, что заставит нас местами кастылизовывать наш солюшн.
  2. Не пытайтесь в сети найти хороший мануал по работе с Microsoft OpenXML: чтобы разобраться с этой библиотекой придется изрядно покрасноглазить, покурить StackOverflow и поиграться с отладчиком.
  3. Да, мне все таки, удалось приручить дракона.

Сразу оговорюсь, что в настоящий момент библиотека еще не готова, но она активно пишется (на столько, на сколько это позволяет свободное время). Предполагается, что будут написаны отдельные посты для каждого формата и параллельно, вместе с их публикацией, будет обновляться репозиторий на гитхабе, откуда можно будет получить исходники.


Работа с xlsx и docx


.xlsx

Наверняка, раз вы читаете эту статью, то уже в курсе, что docx и xlsx фактически являются zip-архивами, в которых лежит множество разных xml. Если нет, то убедиться в этом не составит труда: меняем расширение файла на zip и открываем любым архиватором. Так, наши листы документа будут лежать по следующему пути: \xl\worksheets.

У меня уже есть подготовленный excel документ, и, если открыть какой-нибудь лист по указанному ранее пути, то мы увидим примерно следующее содержимое:

wu0hsi7jshuxcgbjb9gyqighmuk.png

Обратите внимание на то, что в ячейках, которые содержат формулы, записаны формулы (внутри тега ) и результат (внутри тега ). Также, ячейки с повторяющимся содержимым отмечены как shared и содержат ссылку на строку в файле sharedStrings.xml, расположенного по пути \xl.
Пока просто имейте ввиду эти особенности: как обрабатывать их будет показано ниже.

Прежде, чем писать наши классы-конвертеры, создадим интерфейс IConvertable:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace ConverterToXml.Converters
{
    interface IConvertable
    {
        string Convert(Stream stream);
        string ConvertByFile(String path);

    }
}

Теперь все наши классы, должны будут реализовывать два метода: string Convert(Stream stream) для работы с потоком (может быть очень полезным, если необходимо получить какую-то информацию из файла без его сохранения на хосте), а также string ConvertByFile(String path) для конвертации непосредственно файла.

Создаем класс XlsxToXml, реализующий интерфейс IConvertable и подключаем через Nuget DocumentFormat.OpenXml (на момент написания, актуальной являлась версия 2.10.0).

Логику обработки документа поместим в отдельный приватный метод string SpreadsheetProcess(Stream memStream), который будет вызываться в string Convert(Stream stream).

        public string Convert(Stream memStream)
        {
            return SpreadsheetProcess(memStream);
        }

Как видно, сама логика реализована в методе *string SpreadsheetProcess(Stream memStream)*:

string SpreadsheetProcess(Stream memStream)
        {
            using (SpreadsheetDocument doc = SpreadsheetDocument.Open(memStream, false))
            {
                memStream.Position = 0;
                StringBuilder sb = new StringBuilder(1000);
                sb.Append("");
                SharedStringTable sharedStringTable = doc.WorkbookPart.SharedStringTablePart.SharedStringTable; 
                int sheetIndex = 0;
                foreach (WorksheetPart worksheetPart in doc.WorkbookPart.WorksheetParts)
                {
                    WorkSheetProcess(sb, sharedStringTable, worksheetPart, doc, sheetIndex);
                    sheetIndex++;
                }
                sb.Append(@"");
                return sb.ToString();
            }
        }

Итак, в методе string SpreadsheetProcess(Stream memStream) происходит следующее:


  1. В блоке using открываем документ excel из потока. За работу с xlsx в библиотеке DocumentFormat.OpenXml отвечает класс SpreadsheetDocument.


  2. Устанавливаем каретку в начало потока и создаем объект StringBuilder sb (сразу на 1000 символов. Используем StringBuilder вместо строк, чтобы несколько оптимизировать процесс и избежать порождения лишних сущностей в виде не нужных стрингов. Также, заранее задаем начальный размер стрингбилдера, чтобы немного сэкономить времени на инициализации и выделении памяти.


  3. Выше я писал про shared ячейки (в которых хранятся повторяемые значения). Так вот, из объекта класса SpreadsheetDocument их можно получить так:
    SharedStringTable sharedStringTable = doc.WorkbookPart.SharedStringTablePart.SharedStringTable.


  4. Далее создаем переменную, в которой будет храниться номер листа и запускаем цикл

    foreach (WorksheetPart worksheetPart in doc.WorkbookPart.WorksheetParts)
                {
                    WorkSheetProcess(sb, sharedStringTable, worksheetPart, doc, sheetIndex);
                    sheetIndex++;
                }

    в котором выполняется обработка каждого листа с помощью вызываемого метода
    WorkSheetProcess(sb, sharedStringTable, worksheetPart, doc, sheetIndex);:

    private void WorkSheetProcess(StringBuilder sb, SharedStringTable sharedStringTable, WorksheetPart worksheetPart, SpreadsheetDocument doc,
            int sheetIndex)
        {
            string sheetName = doc.WorkbookPart.Workbook.Descendants().ElementAt(sheetIndex).Name.ToString();
            sb.Append($"");
            foreach (SheetData sheetData in worksheetPart.Worksheet.Elements())
            {
                if (sheetData.HasChildren)
                {
                    foreach (Row row in sheetData.Elements())
                    {
                        RowProcess(row, sb, sharedStringTable);
                    }
                }
            }
            sb.Append($"");
        }

  5. Пожалуй, в данной функции больше всего вопросов вызывает строчка:
    string sheetName = doc.WorkbookPart.Workbook.Descendants().ElementAt(sheetIndex).Name.ToString();
    То, что таким образом мы получаем имя листа, думаю понятно. Но вот, чтобы добраться до нее придется воспользоваться отладчиком и методом научного тыка. Поэтому не стесняемся, ставим точку остановки, жмакаем shift+F9(или как там у вас), открываем переменную doc(в которой лежит наш документ)→WorkbookPart→Workbook и вызываем метод Descendants (), который вернет коллекцию всех дочерних элементов типа Sheet. Ну, а дальше остается по индексу получить конкретный лист, вытащить его имя и преобразовать в строку (что и сделано в коде). Как это примерно выглядит показано на рисунке ниже: mirbek__plrqys_9zvmovwhszng.png


  6. Далее по коду в цикле foreach получаем данные из листа, которые представляют собой коллекцию строк. Если внутри объекта sheetData есть какие-то элементы, то это строки, каждую из которых мы обработаем методом RowProcess:

    foreach (SheetData sheetData in worksheetPart.Worksheet.Elements())
            {
                if (sheetData.HasChildren)
                {
                    foreach (Row row in sheetData.Elements())
                    {
                        RowProcess(row, sb, sharedStringTable);
                    }
                }
            }

  7. В методе void RowProcess(Row row, StringBuilder sb, SharedStringTable sharedStringTable) происходит следующее:

    void RowProcess(Row row, StringBuilder sb, SharedStringTable sharedStringTable)
        {
            sb.Append("");
            foreach (Cell cell in row.Elements())
            {
                string cellValue = string.Empty;
                sb.Append("");
                if (cell.CellFormula != null)
                {
                    cellValue = cell.CellValue.InnerText;
                    sb.Append(cellValue);
                    sb.Append("");
                    continue;
                }
                cellValue = cell.InnerText;
                if (cell.DataType != null && cell.DataType == CellValues.SharedString)
                {
                    sb.Append(sharedStringTable.ElementAt(Int32.Parse(cellValue)).InnerText);
                }
                else
                {
                    sb.Append(cellValue);
                }
                sb.Append("");
            }
            sb.Append("");
        }

    В цикле foreach (Cell cell in row.Elements()) проверяем каждую ячейку на предмет наличия в ней записанной формулы:

    if (cell.CellFormula != null)
                {
                    cellValue = cell.CellValue.InnerText;
                    sb.Append(cellValue);
                    sb.Append("");
                    continue;
                }

    Если формула обнаружена, то получаем значение, вычисленное по формуле (cellValue = cell.CellValue.InnerText;) и переходим к следующей ячейке.
    Если ячейка не содержит формулы, то мы проверяем, является ли она shared: если является, то берем значение по индексу из ранее полученной коллекции с повторяющимися значениями:

    if (cell.DataType != null && cell.DataType == CellValues.SharedString)
                {
                    sb.Append(sharedStringTable.ElementAt(Int32.Parse(cellValue)).InnerText);
                }

    В противном случае, мы просто получаем значение из ячейки.


.docx

Начнем с того, что парсинг документов word представляет из себя куда более не тривиальную задачу по сравнению с парсингом excel-файлов.
Так, разработчику предстоит решить проблему не только парсинга содержимого, но и сохранения структуры, что подразумевает, как минимум, сохранение абзацев, обработку списков и таблиц. Так как мои рабочие задачи не подразумевали обработку графики, сносок, оглавления и т.д., в данной статье они разобраны не будут, но, я не исключаю, что когда-нибудь мне придется столкнуться с такой задачей и, я обязательно обновлю и статью, и репозиторий.

Итак, для начала пара слов о внутреннем устройстве документа. Предлагаю снова проделать процедуру с переименованием расширения файла в zip и открыть его любым архиватором. Внутри мы увидим несколько папок. Открываем папку word и находим файл document. Да, внутри лежит еще куча файлов, но они, по большому счету, для решения нашей задачи не нужны. Однако, никто вам не запрещает в них поковыряться: вдруг вам потребуется вытащить какие-нибудь стили из документа.

Как мы видим, содержимое каждого абзаца находится внутри тега w: t, который лежит внутри w: r, который также находится внутри w: p. По большому счету, эта структура является ключевой для всех документов docx, независимо от их содержимого. Обратите внимание на списки: каждый элемент также находится внутри описанной структуры, но с добавлением тегов w: numPr, внутри которого определяется уровень вложенности списка (w: ilvl) и id списка, которому принадлежит данный элемент (w: numId).
evmwa9h1h1hb1h50fdxqg9kpb8k.png
Также, хочу обратить внимание, что индексы элементов списка не хранятся в виде значения в данном файле, а, как мне кажется (во всяком случае, других версий я не нашел), формируются динамически, в зависимости от id списка, которому принадлежит элемент, уровня вложенности и порядкового номера элемента.
Аналогичная история со вложенными списками, которые отличаются от простых списков лишь тем, что у них не нулевой уровень вложенности:
qt9tc9palxsu_6qdxtxwletzmam.png
Более того, данная структура сохраняется и для таблиц. Правда теперь она упакована в теги w: tr (строка) и w: tc (ячейка).
fxahrshoxihokxa8h6u6fxbfdci.png


Прежде, чем начать кодить, хочу обратить внимание на один очень важный ньюанс (да-да, как в анекдоте про Петьку и Василия Ивановича). При разборе списков, особенно это касается вложенных, может возникнуть ситуация, когда пункты списка разделены какой-то вставкой текста, изображения или вообще чего угодно. Тогда возникает вопрос, когда же нам ставить закрывающий тег списка? Мое предложение попахивая костылезацией и велосипедостроением сводится к добавлению словаря, ключами которого будут выступать id списков, а значение будет соответствовать id параграфа (да, оказывается каждый парагарф в документе имеет свой уникальный id), который одновременно является последним в каком-то списке. Пожалуй, написано довольно сложно, но, думаю, когда посмотрите на реализацию, станет несколько понятнее:

public string Convert(Stream memStream)
{
    Dictionary listEl = new Dictionary();
    string xml = string.Empty;
    memStream.Position = 0;
    using (WordprocessingDocument doc = WordprocessingDocument.Open(memStream, false))
    {
        StringBuilder sb = new StringBuilder(1000); 
        sb.Append("");
        Body docBody = doc.MainDocumentPart.Document.Body;
        CreateDictList(listEl, docBody);
        foreach (var element in docBody.ChildElements)
        {
            string type = element.GetType().ToString();
            try
            {
                switch (type)
                {
                    case "DocumentFormat.OpenXml.Wordprocessing.Paragraph":
                        if (element.GetFirstChild() != null)
                        {
                            if (element.GetFirstChild().GetFirstChild().GetFirstChild().Val != CurrentListID)
                            {
                                CurrentListID = element.GetFirstChild().GetFirstChild().GetFirstChild().Val;
                                sb.Append($"
  • "); InList = true; ListParagraph(sb, (Paragraph)element); } else { ListParagraph(sb, (Paragraph)element); } if (listEl.ContainsValue(((Paragraph)element).ParagraphId.Value)) { sb.Append($"
  • ().GetFirstChild().GetFirstChild().Val}\">"); } continue; } else { SimpleParagraph(sb, (Paragraph)element); continue; } case "DocumentFormat.OpenXml.Wordprocessing.Table": Table(sb, (Table)element); continue; } } catch (Exception e) { continue; } } sb.Append(@"
    "); xml = sb.ToString(); } return xml; }


    1. Dictionary listEl = new Dictionary(); — словарь в котором будет храниться информация о последних элементах каждого из списков.


    2. using (WordprocessingDocument doc = WordprocessingDocument.Open(memStream, false)) — создаем объект doc класса WordprocessingDocument, в котором находится содержимое нашего документа word, но уже в структурированном (на столько, на сколько это позволяет библиотека OpenXML) виде.


    3. StringBuilder sb = new StringBuilder(1000); — наша будущая строка с легко читаемым содержимым в формате xml.


    4. Body docBody = doc.MainDocumentPart.Document.Body; — получаем содержимое нашего документа, с которым мы дальше и будем работать


    5. Вызываем функцию CreateDictList(listEl, docBody);, которая пробегается в цикле foreach по всем элементам документа, и ищет последний абзац для каждого списка:

      void CreateDictList(Dictionary listEl, Body docBody)
      {
      foreach(var el in docBody.ChildElements)
      {
          if(el.GetFirstChild() != null)
          {
              int key = el.GetFirstChild().GetFirstChild().GetFirstChild().Val;
              listEl[key] = ((DocumentFormat.OpenXml.Wordprocessing.Paragraph)el).ParagraphId.Value;
          }
      }
      }

      GetFirstChild().GetFirstChild().GetFirstChild().Val; — написание подобного рода страшных конструкций является отнюдь не результатом штудирования документации (можете перейти на сайт мелкомягких https://docs.microsoft.com/ru-ru/office/open-xml/open-xml-sdk и попытаться раскурить их мануал), а представляет собой порождение научного тыка в режиме дебага. Если есть люди, которые владеют методологией изучения подобных библиотек с аналогичной детализацией документации, буду рад, если поделитесь своим опытом)


    6. После того, как наш словарь создан, в цикле foreach перебираем все элементы в документе. На каждой итерации цикла выясняем к какому типу относится наш элемента: абзац или таблица. Если абзац, то мы должны произвести проверку, а не является ли наш абзац частью списка. И если он является элементом списка, то нужно выяснить в какой части списка находится данный абзац (начало, конец или середина) для того, чтобы корректно расставить открывающиеся и закрывающиеся теги для нашего списка. Помимо этого, также важно идентифицировать к какому именно списку относится наш элемент. В коде эта задача решается так:

      string type = element.GetType().ToString();
                     try
      {
      switch (type)
      {
          case "DocumentFormat.OpenXml.Wordprocessing.Paragraph":
      
              if (element.GetFirstChild() != null) // список / не список
              {
                  if (element.GetFirstChild().GetFirstChild().GetFirstChild().Val != CurrentListID)
                  {
                      CurrentListID = element.GetFirstChild().GetFirstChild().GetFirstChild().Val;
                      sb.Append($"
    7. "); InList = true; ListParagraph(sb, (Paragraph)element); } else // текущий список { ListParagraph(sb, (Paragraph)element); } if (listEl.ContainsValue(((Paragraph)element).ParagraphId.Value)) { sb.Append($"
    8. ().GetFirstChild().GetFirstChild().Val}\">"); } continue; } else // не список { SimpleParagraph(sb, (Paragraph)element); continue; } case "DocumentFormat.OpenXml.Wordprocessing.Table": Table(sb, (Table)element); continue; } }

      Блок try-catch используется в связи с тем, что существует вероятность наличия в документе какого-то элемента, который не предусмотрен в блоке switch-case (в нашем случае, мы производим обработку только абзацев, списков и таблиц). Таким образом, если в документе есть что-то неопознанное и нами не предвиденное, то программа просто проигнорирует такой кейс.


    9. Если элемент является частью списка, то он обрабатывается с помощью метода ListParagraph(sb, (Paragraph)element);:

      void ListParagraph(StringBuilder sb, Paragraph p)
      {
      // уровень списка
      var level = p.GetFirstChild().GetFirstChild().GetFirstChild().Val;
      // id списка
      var id = p.GetFirstChild().GetFirstChild().GetFirstChild().Val;
      sb.Append($"

        {p.InnerText}

      "); }

      По большому счету данный метод всего лишь упаковывает содержимое параграфа в теги

        , дополняя его информацией об id списка и уровне вложенности.


      • Если же, текущий элемент не является списком или таблицей, то он обрабатывается с помощью метода SimpleParagraph(sb, (Paragraph)element);:

        void SimpleParagraph(StringBuilder sb, Paragraph p)
        {
        sb.Append($"

        {p.InnerText}

        "); }

        То есть, содержимое текста просто оборачивается в тег


      • Таблица обрабатывается в методе Table(sb, (Table)element);:

        void Table(StringBuilder sb, Table table)
        {
        sb.Append("");
        foreach (var row in table.Elements())
        {
        sb.Append("");
        foreach (var cell in row.Elements())
        {
        sb.Append($"{cell.InnerText}");
        }
        sb.Append("");
        }
        sb.Append("
        ");}

        Обработка такого элемента вполне тривиальна: считываем строки, разбиваем на ячейки, из ячеек берем значения, оборачиваем в теги , которые запаковываем в в теги и все это помещаем внутрь

        .


        На этом, поставленную задачу предлагаю считать решенной для документов формата docx и xlsx.

        Исходный код можно посмотреть в репозитории по ссылке

        © Habrahabr.ru