Не прошло и полугода со дня публикации моей предыдущей статьи про формирование чистого XML из офисного документа. В этот раз расскажу про формат Open Document Format (ODF) и как можно получить «причесанный» XML из текстовых документов с расширением .odt. В следующей статье покажу, как обработать .ods, и завершу данный цикл статей.
Казалось бы, для многих ODF – экзотичный формат и встретиться с ним шансов не очень много. Тем не менее, требования к документам этого формата покрыты ГОСТом (ГОСТ Р ИСО/МЭК 26300-2010), а живое подтверждение их использования можно встретить на порталах некоторых государственных и окологосударственных структур. Когда передо мной встала задача попарсить один из таких источников, я и столкнулся с необходимостью привести кучу ODF документов к единому формату.
Кстати, для тех, кого еще не коснулась острая нужда поковыряться под капотом текстовых документов, может оказаться полезным посмотреть на примеры чтения и записи XML. Дело в том, что мне тонко намекнули, что мои «кнопкотыкательные» органы произрастают из тазобедренного сустава, и, поэтому, на смену привычным XmlDocument и StringBuilder пришли XmlReader и XmlWriter.
О преимуществах использования этих библиотек расскажу ниже.
Итак, поехали!
Представим, что у нас имеется некий файл с расширением .odt, внутри которого есть текст, таблица и списки (простые и разноуровневые). Для примера предлагаю использовать текст моей предыдущей статьи – просто сохраним его в формате .odt:
Содержимое документа
Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:Это таблица
Колонка 1 |
Колонка 2 |
Колонка 3 |
Поле 1/1 |
Поле 2/1 |
Поле 3/1 |
Поле 1/2 |
Поле 2/2 |
Поле 3/2 |
Привет! Удачи, тебе!
Это мой список
1. Первый
2. Второй
3. Третий
4. Последний
Это не список:
Это тоже не список
Это таблица
Колонка 1 |
Колонка 2 |
Колонка 3 |
Поле 1/1 |
Поле 2/1 |
Поле 3/1 |
Поле 1/2 |
Поле 2/2 |
Поле 3/2 |
1.1 Первый.Первый
1.2 Первый.Второй
1.2.1 Первый.Второй.Первый
1.2.2 Первый.Второй.Второй
Какая-то строчка
1.2.3 Первый.Второй.Третий
2. Второй
2.1 Второй.Первый
Перед тем как перейти к коду, покажу, что скрывается внутри .odt-файла. Меняю расширение .odt на .zip и смотрю содержимое с помощью любого архиватора:
Да, нечто аналогичное мы могли наблюдать внутри файлов с расширением .docx (подробнее читайте здесь). Полагаю, что по названиям файлов несложно догадаться о назначении каждого. Нас же интересует исключительно content.xml, так как наша задача – исключительно получение непосредственно структурированного текста, лежащего внутри документа, очищенного от любой информации, не являющейся текстовой. Все, что мне нужно, хранится в файле content.xml. Его и открываю, используя Notepad++. Кстати, структура файла совсем не похожа на content.xml, лежащего внутри .docx файла:
На скрине видно, что в начале файла находится описание стилей, применимых к различным компонентам документа. В целом же, если сравнивать структуру .odt-файла c .docx, то она на порядок проще и прозрачнее. Здесь нет хитрой индексации каждого элемента документа, как это было в .docx файлах, отсутствует куча тегов, зачастую выполняющих дублирующие функции, а описание стилей лежит в одном файле с контентом. Ниже можете сравнить .odt с .docx:
DOCX
ODT
Видно, что структура документа .odt значительно лаконичнее и очевиднее, чем структура документа .docx. Единственный индекс, предусмотренный для элементов документа, – имя стиля, который должен быть применен. Если есть желание досконально разобраться в структуре документа (например, если перед вами встанет задача самостоятельно сгенерировать .odt-файл в соответствии со спецификацией), то вот по этой ссылке можно найти исчерпывающую информацию.
А теперь давайте кодить помаленьку, попутно разбираясь в структуре .odt.
Предлагаю, сразу работать с .odt-документом как с потоком, а не как с файлом. Нам это позволит, во-первых, не захламлять репозиторий, в котором расположено наше приложение, а во-вторых, с большой долей вероятности перед читающим может стоять задача обработки документов, которые были получены, как и в моем случае, посредством REST-API:
string path = @$"Files\\odt1.odt"; if (!Path.IsPathFullyQualified(path)) { path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path); } FileStream fs = new FileStream(path, FileMode.Open);
Вытаскиваем содержимое из архива, лежащего в нашем потоке, находим файл content.xml и возвращаем байтовый массив:
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(); } }
А сейчас я предлагаю свернуть Visual Studio и снова открыть content.xml. Перейдем в блок, заключенный между тегами <body>
, и посмотрим, как хранится интересующая нас информация:
1. Как и следовало ожидать, простой текст разбит по абзацам и заключен внутри тега:
<text:p text:style-name="P1">Здесь какой-то текст</text:p>
При этом нужно помнить о ситуациях, когда часть абзаца имеет свой стиль (например, символы, выделенные курсивом или полужирным шрифтом). Так, если мы выделим полужирным слово «Это» в строчке «Это тоже не список», то представленный ниже XML блок из вот такого
<text:p text:style-name="Обычный"> <text:tab/> <text:span text:style-name="T32">Это тоже не список</text:span> </text:p>
превратится вот в такой:
<text:p text:style-name="Обычный"> <text:tab/> <text:span text:style-name="T32">Это</text:span> <text:s/>тоже не список </text:p>
Таким образом, область, выделенная полужирным, окажется внутри тега <span>
и будет иметь стиль, описывающий необходимое форматирование. Более того, если текст, заключенный между тегами, начинается или заканчивается пробельным символом, то такой символ заменяется тегом <text:s/>
.
Этот момент нужно иметь в виду при обработке документа.
2. Доходим до таблицы:
<table:table table:style-name="Table3"> <table:table-columns> <table:table-column table:style-name="TableColumn4"/> <table:table-column table:style-name="TableColumn5"/> <table:table-column table:style-name="TableColumn6"/> </table:table-columns> <table:table-row table:style-name="TableRow7"> <table:table-cell table:style-name="TableCell8"> <text:p text:style-name="P9">Колонка 1</text:p> </table:table-cell> <table:table-cell table:style-name="TableCell10"> <text:p text:style-name="P11">Колонка 2</text:p> </table:table-cell> <table:table-cell table:style-name="TableCell12"> <text:p text:style-name="P13">Колонка 3</text:p> </table:table-cell> </table:table-row> <table:table-row table:style-name="TableRow14"> <table:table-cell table:style-name="TableCell15"> <text:p text:style-name="P16">Поле 1/1</text:p> </table:table-cell> <table:table-cell table:style-name="TableCell17"> <text:p text:style-name="P18">Поле 2/1</text:p> </table:table-cell> <table:table-cell table:style-name="TableCell19"> <text:p text:style-name="P20">Поле 3/1</text:p> </table:table-cell> </table:table-row> <table:table-row table:style-name="TableRow21"> <table:table-cell table:style-name="TableCell22"> <text:p text:style-name="P23">Поле 1/2</text:p> </table:table-cell> <table:table-cell table:style-name="TableCell24"> <text:p text:style-name="P25">Поле 2/2</text:p> </table:table-cell> <table:table-cell table:style-name="TableCell26"> <text:p text:style-name="P27">Поле 3/2</text:p> </table:table-cell> </table:table-row> </table:table>
Видим, что таблица оформляется тегом <table>
, внутри которого заключаются остальные элементы таблицы: теги ячеек (<table:table-cell/>
), колонок (<table:table-row/>
) и абзацев (<text:p>
).
3. Простой список:
<text:list text:style-name="LFO1" text:continue-numbering="true"> <text:list-item> <text:p text:style-name="P28">Первый</text:p> </text:list-item> <text:list-item> <text:p text:style-name="P29">Второй</text:p> </text:list-item> <text:list-item> <text:p text:style-name="P30">Третий</text:p> </text:list-item> <text:list-item> <text:p text:style-name="P31">Последний</text:p> </text:list-item> </text:list>
Как можно заметить, структура списка в .odt-файлах без сюрпризов: тегами <text:list>
оформляется список, а его элементы заключены в теги <text:list-item>
.
4. Многоуровневый список также выглядит вполне предсказуемо и представляет из себя список, в который вставили другие списки:
<text:list text:style-name="LFO2" text:continue-numbering="true"> <text:list-item> <text:list> <text:list-item> <text:p text:style-name="P57">Первый.Первый</text:p> </text:list-item> <text:list-item> <text:p text:style-name="P58">Первый.Второй</text:p> <text:list text:continue-numbering="true"> <text:list-item> <text:p text:style-name="P59"> <text:span text:style-name="T60">Первый.Второй.Первый</text:p> </text:list-item> <text:list-item> <text:p text:style-name="P61">Первый.Второй.Второй</text:p> </text:list-item> </text:list> </text:list-item> </text:list> </text:list-item> </text:list> <text:p text:style-name="P62">Какая-то строчка</text:p> <text:list text:style-name="LFO2" text:continue-numbering="true"> <text:list-item> <text:list> <text:list-item> <text:list> <text:list-item> <text:p text:style-name="P63">Первый.Второй.Третий</text:p> </text:list-item> </text:list> </text:list-item> </text:list> </text:list-item> <text:list-item> <text:p text:style-name="P64">Второй</text:p> <text:list text:continue-numbering="true"> <text:list-item> <text:p text:style-name="P65"> <text:span text:style-name="T66">Второй.Первый</text:span> </text:p> </text:list-item> </text:list> </text:list-item> </text:list>
Ключевое отличие многоуровневого списка от обычного заключается в том, что все подуровни дополнительно заключены в теги <text:list>. Да, это отнюдь не похоже на структуру документов .docx, подробнее о которой можно прочитать здесь. Также обратите внимание на то, что в документах .odt отсутствует нумерация абзацев, а текстовый процессор/редактор отличает новый список от элементов, являющихся частью текущего списка, с помощью параметра text:continue-numbering="true"
.
Теперь, после того как мы препарировали content.xml, предлагаю перейти к написанию кода. (Уверен, что большинство читателей, открывших эту статью не из праздного интереса, а потому что понадобилось по работе, пропустили все написанное выше и уже ищут ссылочку на GitHub с этим проектом).
Формирование нового XML
1. Обработку исходного файла .xml будет производить метод ClearXml
, который в качестве параметра принимает Stream
(почему следует обрабатывать поток, я описал в начале статьи). Чтение .xml будет осуществляться с помощь XmlReader
. Преимущество данного подхода перед работой с помощью XmlDocument состоит в том, что не придется загружать в оперативную память документ целиком – вместо этого программа будет считывать данные из потока, двигаясь от одного тега к следующему.
Плату за сэкономленную память и ускоренную работу составят определенные сложности при отладке, а также не самые изящные строчки кода при ориентации по тегам.
Формирование и запись нового .xml будет осуществляться с помощью XmlWriter
. Этот инструмент помогает инкапсулировать логику, связанную с расстановкой тегов, и записи значений внутри тегов, а также избегать проблем с закрывающимися тегами. Результат работы библиотеки – формирование объекта StringBuilder
. Вместе с тем XmlWriter
умеет работать и с потоками, и с различными обертками над StringBuilder
(например, TextWriter
). В нашем случае использование StringBuilder
для хранения выходных данных, сформированных XmlWriter
, оказалось достаточным.
Ниже привожу код описанного метода:
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); reader.ReadToFollowing("office:body"); while (reader.Read()) { MethodSwitcher(reader, writer); } } return sb.ToString(); }
Обратите внимание на необходимость произвести настройку работы XmlWriter
путем создания объекта XmlWriterSettings
с ConformanceLevel.Auto
. Такая конфигурация необходима для обеспечения правильного формирования закрывающихся тегов при вызове метода XmlWriter.WriteEndElement()
.
С помощью метода reader.ReadToFollowing(“office:body”)
производится смещение «каретки» до тега <office:body>
, чтобы пропустить обработку части документа, содержащей теги описания стилей.
Движение внутри исходного документа осуществляется с помощью метода reader.Read()
, который обновляет данные, загруженные в объект reader
. Такая навигация обусловлена непредсказуемостью содержания .xml-документа (мы не знаем когда будет закрыт текущий тег и какое количество дочерних элементов содержит родительский), а также невозможностью осуществлять навигацию по определенным тегам, не выходя за пределы текущего родительского тега. Единственное, что нам предлагает XmlReader
, это метод ReadToFollowing()
. В качестве второго аргумента он может принять пространство имен, которое ограничит работу указанного метода. То есть попытка написания вот такой конструкции:
while (reader.Prefix == "table") { reader.ReadToFollowing("table:table-row"); }
приведет к тому, что reader пробежится по всему документу в поисках тега <table:table-row>
, игнорируя тот факт, что какие-то из них будут относиться к другим таблицам.
2. Обработку текущего узла .xml производит метод MethodSwitcher. Из названия понятно, что его работа заключается в выборе метода, описывающего дальнейшее поведение XmlWriter. Выбор осуществляется в зависимости от значения reader.NodeType.
Вообще XmlTypeNode является перечислением с кучей именованных констант, но только три из них представляют интерес в рамках текущей задачи: Element, EndElement и Text. Соответственно, в первом случае тег должен открыться, во втором – закрыться, в третьем должно быть записано некоторое строковое значение. Остальные ситуации должны быть проигнорированы. Ниже представлена реализация:
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; } }
Можете видеть, что метод представляет собой симбиоз конструкции switch-case
и нескольких if
.
Так, первый if
обусловлен тем, content.xml может содержать ряд одиночных тегов, которые не должны попасть в очищенный .xml (например, пустая строка в документах формата .odt оформляется одиночным тегом <text:p />
), но в то же время пробельные символы, оформленные с помощью тегов (<text:s>
), должны быть сохранены.
Второй if
обеспечивает защиту от преждевременной записи закрывающихся тегов. Такая ситуация возможна, если «каретка» внутри content.xml доходит до тега, закрывающего узел, который не должен быть записан. Например, вот так хранится строчка из трех слов, одно из которых выделено полужирным шрифтом:
<text:p text:style-name="Обычный"> Это<text:s/> <text:span text:style-name="T28">мой</text:span> <text:s/>список </text:p>
Оператор ветвления, осуществляющий проверку, является ли текущий тег допустимым, был добавлен для того, чтобы тег <p>
в новом .xml не был закрыт в момент, когда «каретка» окажется на теге </text:span>. Допустимые теги хранятся в коллекции:
private readonly List<string> tags = new List<string>() { "p", "table", "table-row", "table-cell", "list", "list-item" };
Запись данных в StringBuilder
осуществляется при вызове метода writer.Flush()
. Его необходимо вызывать строго после вызова метода writer.WriteEndElement()
, иначе запись закрывающего тега становится невозможной, так как при вызове Flush()
происходит не только запись данных в указанный объект, но и очистка кеша текущего writer
.
3. Метод TagWriter
, вызываемый при достижении «каретки» тега, открывающего узел, который должен быть записан в новый .xml-файл, содержит в себе очередной switch-case. По сути, конструкция содержит те теги, из которых будет сформирован новый очищенный .xml. Все остальные теги будут проигнорированы. Ниже приведена реализация метода.
private void TagWriter(XmlReader reader, XmlWriter writer) { switch (reader.LocalName) { case "p": writer.WriteStartElement("p"); break; case "table": writer.WriteStartElement("table"); 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; } }
Вот таким достаточно лаконичным и быстрым способом можно обработать «кудрявую» .xml-структуру, лежащую в основе .odt-файла, и получить на выходе аккуратный .xml-документ, с которым будет легко и комфортно работать в автоматизированном режиме. Ниже представлен результат обработки документа, содержание которого было дано на скрине в начале статьи.
<p>Это таблица</p> <table> <row> <cell> <p>Колонка 1</p> </cell> <cell> <p>Колонка 2</p> </cell> <cell> <p>Колонка 3</p> </cell> </row> <row> <cell> <p>Поле 1/1</p> </cell> <cell> <p>Поле 2/1</p> </cell> <cell> <p>Поле 3/1</p> </cell> </row> <row> <cell> <p>Поле 1/2</p> </cell> <cell> <p>Поле 2/2</p> </cell> <cell> <p>Поле 3/2</p> </cell> </row> </table> <p>Привет! Удачи, тебе!</p> <p>Это мой список</p> <list> <item> <p>Первый</p> </item> <item> <p>Второй </p> </item> <item> <p>Третий</p> </item> <item> <p>Последний</p> </item> </list> <p>Это не список:</p> <p>Это тоже не список</p> <p>Это таблица</p> <table> <row> <cell> <p>Колонка 1</p> </cell> <cell> <p>Колонка 2</p> </cell> <cell> <p>Колонка 3</p> </cell> </row> <row> <cell> <p>Поле 1/1</p> </cell> <cell> <p>Поле 2/1</p> </cell> <cell> <p>Поле 3/1</p> </cell> </row> <row> <cell> <p>Поле 1/2</p> </cell> <cell> <p>Поле 2/2</p> </cell> <cell> <p>Поле 3/2</p> </cell> </row> </table> <list> <item> <list> <item> <p>Первый.Первый</p> </item> <item> <p>Первый.Второй</p> <list> <item> <p>Первый.Второй.Первый</p> </item> <item> <p>Первый.Второй.Второй</p> </item> </list> </item> </list> </item> </list> <p>Какая-то строчка </p> <list> <item> <list> <item> <list> <item> <p>Первый.Второй.Третий</p> </item> </list> </item> </list> </item> <item> <p>Второй</p> <list> <item> <p>Второй.Первый</p> </item> </list> </item> </list>
Думаю, что изложенного алгоритма обработки текстового документа в формате .odt должно хватить для большинства задач, связанных с извлечением текстовой информации из файлов с указанным расширением.
Традиционно, код данного решения уже размещен в моем репозитории на GitHub, откуда вы можете его скачать и сразу же приступить к его использованию.
ссылка на оригинал статьи https://habr.com/ru/company/auriga/blog/549750/
Добавить комментарий