Конвертируем ODS в XML

  • Tutorial

Вот, наконец, я и добрался до последней статьи, завершающей цикл мануалов, посвященных конвертации различных офисных документов в xml-файлы. На этот раз я поделюсь опытом получения «чистого» xml-файла из таблиц, сохраненных в формате ODS. 

Отвечая на вопрос «А зачем оно мне надо?» скажу, что ODS, кроме того, что является бесплатным форматом для электронных таблиц, разработанным индустриальным сообществом OASIS, еще и принят в качестве ГОСТовского стандарта в РФ. А это значит, что часть документов, опубликованных на порталах различных государственных и окологосударственных структур, которые, как им кажется, умеют в инновации, опубликованы именно в этом формате. Учитывая, что мануалов по причесыванию этой экзотики не так уж много (лично я не нашел, когда решал эту задачу), думаю, что кому-нибудь мой опыт поможет сэкономить время и нервные клетки.

Ссылки на другие статьи цикла

Итак, ODS представляет собой архив, в котором лежит набор .xml-файлов, хранящих не только текстовую информацию, но и различного рода метаданные, стили и прочие сведения, необходимые офисному приложению для нормальной работы с документом. Вообще, подробно о формате .ods можно почитать в спецификации. Забегая вперед, скажу, что доскональное изучение всех используемых тегов, их атрибутов, а также внутреннего устройства архива с расширением .ods может понадобиться только в том случае, если перед разработчиком будет поставлена задача вручную собрать документ по стандарту ODF. Честно говоря, если ваша компания не пишет очередную реализацию MS Office, то вероятность развития такого сценария немного очень сильно стремится к нулю.

Для примера предлагаю создать какой-нибудь файл с таблицами в любом удобном офисном приложении, заполнить тестовыми данными и сохранить его в формате ODS. Должно получиться что-то вроде того, что изображено на скрине ниже:

Чтобы добраться до XML с контентом, нужно сменить расширение файла с .ods на .zip и открыть его в любом архиваторе. Я использую 7-zip. Внутри архива хранятся следующие файлы:

Так как меня интересует содержание документа, то закапываю "Визин", надеваю очки +10 дптр и открываю content.xml. Строго говоря, остальные файлы в архиве не представляют интереса, так как в них содержатся исключительно метаданные, стили и иная информация, парсить которые никто, конечно же, не будет.

В результате, мы увидим следующее содержимое:

Несмотря на ломающий глаза текст, можно заметить, что первая часть документа содержит описание стилей, определяющих ширину колонок для отображения контента. Чтобы лучше разглядеть содержимое, настоятельно предлагаю самостоятельно проделать описанные манипуляции, а не ломать глазки о скрины. Сам же контент расположен ниже и заключен внутри тегов <office:body>

Предлагаю подробнее рассмотреть блок с контентом:

  1. Как известно, типичный табличный документ (Excel или ODF) содержит в себе таблицы, или листы, образующие книгу (фактически один блок внутри тегов <office:spreadsheet>). Содержимое листов заключено внутри тегов, атрибуты которых указывают на имя листа и применимый к нему стиль <table:table table:name="Лист1" table:style-name="ta1">.

  2. Наименьшей самостоятельной единицей таблицы является ячейка. Типичные теги, в которые заключается содержимое ячейки, выглядит следующим образом <table:table-cell office:value-type="string" table:style-name="ce1">. Таким образом, с помощью параметров можно указать тип данных, лежащих внутри ячейки, а также стиль. Сам же текстовый контент заключается внутри тегов <text:p>.

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

    <table:table-cell office:value-type="float" office:value="3" 
                      table:formula="of:=SUM([.A2:.C2])" table:style-name="ce1">
    	<text:p>3</text:p>
    </table:table-cell>
  3. Ячейки объединяются в строки, которые выделяются тегами <table:table-row table:style-name="ro1">. Ниже, для иллюстрации, представлен кусочек xml со строкой из тестового файлика:

    <table:table-row table:style-name="ro1">
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>первый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>второй</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>третий</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>четвертый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>пятый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>первый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>второй</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>третий</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>четвертый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>пятый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>первый</text:p>
              </table:table-cell>
              <table:table-cell office:value-type="string" table:style-name="ce1">
                <text:p>второй</text:p>
              </table:table-cell>
              <table:table-cell table:number-columns-repeated="16372" table:style-name="ce1"/>
    </table:table-row>

    Информация к размышлению!

    Обратите внимание на следующую строчку:

    <table:table-cell table:number-columns-repeated="16372" table:style-name="ce1"/>

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

  4. И еще один момент, про который нельзя забывать – собственное форматирование текста внутри ячейки, которое в файле content.xml выглядит следующим образом:

<table:table-cell office:value-type="string" table:number-columns-spanned="2" 
                  table:number-rows-spanned="2" table:style-name="ce2">
    <text:p>
        <text:span text:style-name="T2">Ячейки</text:span>
         <text:s/>
         Объединены
     </text:p>
</table:table-cell>

То есть при чтении content.xml необходимо учесть, что текст, имеющий отдельное оформление (полужирный, курсив или все что угодно, делающее оформление текста отличным от оформления по умолчанию) заключается в теги <text:span>, пробел при этом обозначается с помощью тега <text:s>.

Думаю, что читатель, который имел опыт работы с внутренней структурой любого другого ODF файла, легко узнал сходство используемых тегов и практически неотличимое от .odt-файлов внутреннее устройство xml-документа.

Довольно теории: го код писать – я создал.

  1. Работать с ODS-файлом лучше как с потоком, чтобы не плодить лишние сущности, да и источником ODS документа может быть что-нибудь, с чем предполагается взаимодействовать посредством REST-запросов.

    public string Convert(Stream stream)
            {
                var content = Unzip(stream);
                using (Stream memoryStream = new MemoryStream(content))
                {
                    return ClearXml(memoryStream);
                }
            }
  2. Обратите внимание на строчку var content = Unzip(stream); в которой мы вызываем метод Unzip. Указанный метод отвечает за извлечение файла content.xml из архива и сохранение результата в виде байтового массива, который может быть преобразован в MemoryStream для дальнейшей обработки:

    private byte[] Unzip(Stream stream)
            {
                // Необходимо копировать входящий поток в MemoryStream, 
      					//т.к. байтовый массив нельзя вернуть из Stream
                using (MemoryStream ms = new MemoryStream())
                {
                    ZipArchive archive = new ZipArchive(stream);
                    var unzippedEntryStream = archive.GetEntry("content.xml")?.Open();
                    unzippedEntryStream?.CopyTo(ms);
                    return ms.ToArray();
                }
            }

    После того, как content.xml был извлечен мы можем произвести его обработку с помощью нативной библиотеки.

  3. System.Xml – это очень удобная библиотека, однако .ods-файлы, как и любые другие офисные файлы, иногда могут достигать неприлично больших размеров, а использование System.Xml означает не просто загрузить офисный документ в память, но и создать кучу объектов с множеством методов и полей. Поэтому чтение из content.xml реализовано с помощью XmlReader. Обрабатывать content.xml я предлагаю путем создания новой xml, которую мы будем собирать поступательно, двигаясь внутри content.xml сверху вниз c помощью XmlWriter.

    	 	    /// <summary>
            /// Обработка xml.
            /// </summary>
            /// <param name="xmlStream"></param>
            /// <returns>Result as <see cref="string"/>.</returns>
            private string ClearXml(Stream xmlStream)
            {
                // Создаем настройки XmlWriter
                XmlWriterSettings settings = new XmlWriterSettings();
                // Необходимый параметр для формирования вложенности тегов
                settings.ConformanceLevel = ConformanceLevel.Auto;
                // XmlWriter будем вести запись в StringBuilder
                StringBuilder sb = new StringBuilder();
    
                using (XmlWriter writer = XmlWriter.Create(sb, settings))
                {
                    XmlReader reader = XmlReader.Create(xmlStream);
                    // пропускаем часть content.xml, которая содержит метатеги и теги стилей.
                    reader.ReadToFollowing("office:spreadsheet"); 
                    while (reader.Read())
                    {
                        MethodSwitcher(reader, writer);
                    }
                }
                return sb.ToString();
            }

    Для обеспечения работы XmlWriter необходимо создать объект XmlWriterSettings и указать параметр ConformanceLevel.Auto, чтобы обеспечить возможность корректного закрытия тегов XmlWriter.

    Строчкой reader.ReadToFollowing("office:spreadsheet"); «прокручиваем» теги с описанием стилей и метатеги, чтобы сразу добраться до смыслового контента.

    Чтение данных из content.xml происходит с помощью метода reader.Read(), который двигает каретку от одного элемента XML к другому. Обработка каждого такого узла реализована в методе MethodSwitcher.

  4. MethodSwitcher осуществляет выбор метода, описывающего дальнейшее поведение XmlWriter. Критерием выбора является тип текущего узла – reader.NodeType. Несмотря на то, что NodeType – это перечисление, содержащее около десятка именованных констант, для решения поставленной задачи достаточно будет трех: Element, EndElement и Text. Соответственно, в первом случае XmlWriter должен записать новый открывающийся тег, во втором случае должен быть закрыт последний незакрытый открытый тег, а в третьем записать некоторое строковое значение, помещенное между тегами.

    private void MethodSwitcher(XmlReader reader, XmlWriter writer)
            {
                switch (reader.NodeType)
                {
                    case XmlNodeType.Element:
                        if (!reader.IsEmptyElement || reader.Name == "text:s")
                        {
                            TagWriter(reader, writer);
                        }
                        break;
                    case XmlNodeType.EndElement:
                        if (tags.Contains(reader.LocalName))
                        {
                            writer.WriteEndElement();
                            writer.Flush();
                        }
                        break;
                    case XmlNodeType.Text:
                        writer.WriteString(reader.Value);
                        break;
                    default: break;
                }
            }

    Как видно, недостаточно просто сделать выбор на основании типа текущего элемента. Необходимо дополнительно обработать одиночные теги (например, пустая строка оформляется тегом </text:span>), исключив их из конечного результата обработки, но в то же время нужно сохранить пробелы (напомню, пробельные символы обозначатся <text:s>).

    Второй If предотвращает преждевременное закрытие тегов. Преждевременное закрытие тега возможно в том случае, если каретка XmlReader доходит до закрывающего тега блока, который не должен быть записан. Для обработки такой ситуации мной был создан список с тегами, которые должны присутствовать в конечном документе:

    private readonly List<string> tags = new List<string>()
            {
                "p",
                "table",
                "table-row",
                "table-cell",
                "list",
                "list-item"
            };

    Вызов writer.Flush() необходим для того, чтобы осуществить запись данных в StringBuilder. Следует помнить, что вызывать указанный метод можно только после того, как будут сформированы все данные, которые должны быть записаны с помощь XmlWriter. Так Flush() не только записывает данные, но и очищает кэш текущего XmlWriter.

  5. Метод TagWriter осуществляет непосредственно запись открывающихся тегов и представляет собой switch-case, в котором осуществляется выбор подходящего имени для тега в зависимости от текущего положения каретки XmlReader:

private void TagWriter(XmlReader reader, XmlWriter writer)
        {
            switch (reader.LocalName)
            {
                case "p":
                    writer.WriteStartElement("p");
                    break;
                case "table":
                    writer.WriteStartElement("table");
                    writer.WriteAttributeString("name", reader.GetAttribute("table:name"));
                    break;
                case "table-row":
                    writer.WriteStartElement("row");
                    break;
                case "table-cell":
                    writer.WriteStartElement("cell");
                    break;
                case "list":
                    writer.WriteStartElement("list");
                    break;
                case "list-item":
                    writer.WriteStartElement("item");
                    break;
                case "s":
                    writer.WriteString(" ");
                    break;
                default: break;
            }
        }

Рубрика «Вы не спрашивали, но мы отвечаем»

Думаю, что у вас есть вопрос насчет объединенных ячеек.  В нашем тестовом файле таковые имеются и в content.xml они выглядят вот так:

<table:table-cell office:value-type="string" table:number-columns-spanned="2" table:number-rows-spanned="2" table:style-name="ce2">
            <text:p>Ячейки объединены</text:p>
</table:table-cell>

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

Описанным способом можно обработать практически любого размера файлы ODS, воспользовавшись исключительно средствами Microsoft, поставляемыми вместе с платформой .Net Core.

Ну, и по традиции, ознакомиться с уже написанным кодом, готовым к употреблению, можно в моем репозитории на GitHub.

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

Гештальт закрыт!

Auriga
Аурига — это люди

Похожие публикации

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое