library(tidyverse)
library(xml2)
6 Разметка TEI XML
XML (от англ. eXtensible Markup Language) — расширяемый язык разметки. Слово “расширяемый” означает, что список тегов не зафиксирован раз и навсегда: пользователи могут вводить свои собственные теги и создавать так называемые настраиваемые языки разметки (Холзнер 2004, 29). Один из таких настраиваемых языков – это TEI (Text Encoding Initiative), о котором будет сказано дальше.
Для работы нам понадобятся следующие библиотеки:
6.1 Основы XML
Назначение языков разметки заключается в описании структурированных документов. Структура документа представляется в виде набора вложенных в друг друга элементов (дерева XML). У элементов есть открывающие и закрывающие теги.
Все составляющие части документа обобщаются в пролог и корневой элемент. Корневой элемент — обязательная часть документа, в которую вложены все остальные элементы. Пролог может включать объявления, инструкции обработки, комментарии.
В правильно сформированном XML открывающий и закрывающий тег вложенного элемента всегда находятся внутри одного родительского элемента.
Создадим простой XML из строки. Сначала идет инструкция по обработке XML (со знаком вопроса), за ней следует объявление типа документа (с восклицательным знаком) и открывающий тег корневого элемента. В этот корневой элемент вложены все остальные элементы.
<- '<?xml version="1.0" encoding="utf-8"?>
string_xml <!DOCTYPE recipe>
<recipe name="хлеб" preptime="5min" cooktime="180min">
<title>
Простой хлеб
</title>
<composition>
<ingredient amount="3" unit="стакан">Мука</ingredient>
<ingredient amount="0.25" unit="грамм">Дрожжи</ingredient>
<ingredient amount="1.5" unit="стакан">Тёплая вода</ingredient>
</composition>
<instructions>
<step>
Смешать все ингредиенты и тщательно замесить.
</step>
<step>
Закрыть тканью и оставить на один час в тёплом помещении.
</step>
<step>
Замесить ещё раз, положить на противень и поставить в духовку.
</step>
</instructions>
</recipe>'
6.1.1 Библиотека xml2
Для работы с xml понадобится установить библиотеку xml2
. Функция read_xml()
создаст объект, представляющий дерево XML.
<- read_xml(string_xml)
doc class(doc)
[1] "xml_document" "xml_node"
Функция xml_root()
позволяет извлечь корневой элемент вместе со всеми детьми.
<- xml_root(doc)
rootnode rootnode
{xml_document}
<recipe name="хлеб" preptime="5min" cooktime="180min">
[1] <title>\n Простой хлеб\n </title>
[2] <composition>\n <ingredient amount="3" unit="стакан">Мука</ingredient>\n ...
[3] <instructions>\n <step>\n Смешать все ингредиенты и тщательно зам ...
У корневого элемента есть “дети”. Это набор узлов.
xml_children(rootnode)
{xml_nodeset (3)}
[1] <title>\n Простой хлеб\n </title>
[2] <composition>\n <ingredient amount="3" unit="стакан">Мука</ingredient>\n ...
[3] <instructions>\n <step>\n Смешать все ингредиенты и тщательно зам ...
У детей есть имена, которые можно извлечь специальной функцией.
xml_name(xml_children(rootnode))
[1] "title" "composition" "instructions"
6.1.2 Выбор элементов
Обращаться к узлам можно по имени или по индексу.
# 1. Выбрать узел по имени:
<- xml_find_first(rootnode, "composition")
composition_node composition_node
{xml_node}
<composition>
[1] <ingredient amount="3" unit="стакан">Мука</ingredient>
[2] <ingredient amount="0.25" unit="грамм">Дрожжи</ingredient>
[3] <ingredient amount="1.5" unit="стакан">Тёплая вода</ingredient>
# 2. Выбрать узел по индексу (например, второй дочерний узел):
<- xml_children(rootnode)[[2]]
composition_node composition_node
{xml_node}
<composition>
[1] <ingredient amount="3" unit="стакан">Мука</ingredient>
[2] <ingredient amount="0.25" unit="грамм">Дрожжи</ingredient>
[3] <ingredient amount="1.5" unit="стакан">Тёплая вода</ingredient>
# 3. Комбинировать выбор: второй узел -> первый элемент:
<- xml_find_first(composition_node, "ingredient")
ingr_node_1 ingr_node_1
{xml_node}
<ingredient amount="3" unit="стакан">
6.1.3 Значения узлов и атрибутов
Но обычно нам нужен не элемент как таковой, а его содержание (значение). Чтобы добраться до него, используем функцию xml_text()
:
xml_text(xml_children(composition_node))
[1] "Мука" "Дрожжи" "Тёплая вода"
Можно уточнить атрибуты узла при помощи xml_attrs()
:
xml_attrs(xml_children(composition_node))
[[1]]
amount unit
"3" "стакан"
[[2]]
amount unit
"0.25" "грамм"
[[3]]
amount unit
"1.5" "стакан"
Чтобы извлечь значение атрибута, используем функцию xml_attr()
. Первым аргументом функции передаем xml-узел, вторым – имя атрибута.
xml_attr(xml_children(composition_node), "unit")
[1] "стакан" "грамм" "стакан"
6.1.4 Синтаксис XPath
Добраться до узлов определенного уровня можно также при помощи синтаксиса XPath. XPath – это язык запросов к элементам XML-документа. С его помощью можно описать “путь” до нужного узла: абсолютный (начиная с корневого элемента) или относительный. В пакете xml
синтаксис XPath поддерживает функция xml_find_all()
.
# абсолютный путь
xml_find_all(rootnode, "/recipe//composition//ingredient")
{xml_nodeset (3)}
[1] <ingredient amount="3" unit="стакан">Мука</ingredient>
[2] <ingredient amount="0.25" unit="грамм">Дрожжи</ingredient>
[3] <ingredient amount="1.5" unit="стакан">Тёплая вода</ingredient>
# относительный путь
xml_find_all(rootnode, "//composition//ingredient")
{xml_nodeset (3)}
[1] <ingredient amount="3" unit="стакан">Мука</ingredient>
[2] <ingredient amount="0.25" unit="грамм">Дрожжи</ingredient>
[3] <ingredient amount="1.5" unit="стакан">Тёплая вода</ingredient>
# атрибут unit == "стакан"
xml_find_all(rootnode, "//composition//ingredient[@unit='стакан']")
{xml_nodeset (2)}
[1] <ingredient amount="3" unit="стакан">Мука</ingredient>
[2] <ingredient amount="1.5" unit="стакан">Тёплая вода</ingredient>
6.1.5 От дерева к таблице
При работе с xml в большинстве случаев наша задача – извлечь значения определеннных узлов или их атрибутов и сохранить их в прямоугольном формате.
# Получаем узлы:
<- xml_text(xml_find_all(rootnode, "title")) |>
title trimws()
<- xml_find_all(rootnode, "./composition/ingredient")
ingredient_ns
tibble(
title = title,
ingredients = xml_text(ingredient_ns),
unit = xml_attr(ingredient_ns, "unit"),
amount = xml_attr(ingredient_ns, "amount")
|> print() )
# A tibble: 3 × 4
title ingredients unit amount
<chr> <chr> <chr> <chr>
1 Простой хлеб Мука стакан 3
2 Простой хлеб Дрожжи грамм 0.25
3 Простой хлеб Тёплая вода стакан 1.5
Теперь рассмотрим более сложные примеры.
6.2 Разметка TEI
TEI (Text Encoding Initiative) — специализированный язык разметки на основе XML, разработанный как средство формального кодирования наиболее значимых текстологических свойств документа: физических параметров рукописи, критического аппарата, лингвистической информации, выходных данных, сведений об авторе, обстоятельствах публикации и первоисточнике (Скоринкин 2016). TEI появился в 1987 г. и в наши дни стал де-факто стандартом для создания цифровых гуманитарных ресурсов.
Основная задача структурированной разметки — формальное эксплицитное представление некоторых свойств документа, заложенных в нем имплицитно. Например, для человека очевидно, где в тексте романа кончается одна часть и начинается другая, какие герои упоминаются в той или иной главе, какие реплики кем произнесены. Однако для машины ничего из этого не является «очевидным» — электронный текст без разметки остается не более чем цепочкой символов.
Большая часть размеченных литературных корпусов хранится именно в формате XML. Это очень удобно, и вот почему: с помощью тегов XML мы можем достать из документа именно то, что нам интересно: определенную главу, речи конкретных персонажей, слова на иностранных языках и т.п.
Использование TEI обеспечивает:
- Хранение богатой метаинформации о тексте и его носителях;
- Кодирование структуры текста и лингвистической разметки;
- Независимость от конкретного ПО;
- Открытость для доработки и расширения;
- Оптимизацию для автоматической обработки.
Добавлять и удалять разметку может любой пользователь в редакторе XML кода или даже в простом текстовом редакторе. Стандарт TEI предоставляет исследователям универсальный метаязык для обмена текстологической информацией и встраивает документы в мировую коллекцию машиночитаемых текстов.
6.2.1 Структура документа TEI
Корневой элемент в документах TEI называется TEI
, внутри него располагается элемент teiHeader
с метаинформацией о документе и элемент text
. Последний содержит текст документа с элементами, определяющими его структурное членение.
<TEI>
<teiHeader></teiHeader>
<text></text>
</TEI>
Пример оформления документа можно посмотреть по ссылке.
6.2.2 teiHeader
У teiHeader
есть четыре главных дочерних элемента:
fileDesc
(описание документа c библиографической информацией)encodingDesc
(описание способа кодирование первоисточника)profileDesc
(“досье” на текст, например отправитель и получатель для писем, жанр, используемые языки, обстоятельства создания, место написания и т.п.)revisionDesc
(история изменений документа).
Элемент fileDesc
должен содержать полную библиографическую информацию о первоисточнике. Пример для повести Л.Н. Толстого «Детство»:
<fileDesc>
<titleStmt>
<title>Повесть «Детство». Электронное издание.</title>
<author>Толстой Л.Н.</author>
<editor>Иванов И.И.</editor>
<respStmt>
<resp>Подготовка и разметка метаинформации для электронного издания</resp>
<name>Иванов И.И.</name>
</respStmt>
</titleStmt>
<publicationStmt>
<publisher>Школа лингвистики <orgName>НИУ ВШЭ</orgName></publisher>
<availability>
<p>Распространяется свободно</p>
</availability>
</publicationStmt>
<sourceDesc>
<biblStruct>
<author>Толстой Л.Н.</author>
<title level="a">Детство</title>
<monogr>
<title level="m">Полное собрание сочинений. Том 1</title>
<imprint>
<pubPlace>Москва</pubPlace>
<publisher>Государственное издательство "Художественная литература"</publisher>
<date when="1935"/>
</imprint>
</monogr>
</biblStruct>
</sourceDesc>
</fileDesc>
Элемент <profileDesc>
содержит метаданные, относящиеся непосредственно к тексту:
<profileDesc>
<creation>
<date when="1852">1852</date>
<placeName>Москва</placeName>
<placeName>станица Старогладковская</placeName>
<placeName>Тифлис</placeName>
</creation>
<langUsage>
<language ident="rus" usage="99">Русский</language>
<language ident="fra" usage="0,5">Французский</language>
<language ident="deu" usage="0,5">Немецкий</language>
</langUsage>
<textClass>
<catRef type="type" target="#short_novel"/>
</textClass>
</profileDesc>
6.2.3 Варианты и исправления
В самом тексте язык TEI дает возможность представлять разные варианты (авторские, редакторские, корректорские и др.) Основным средством параллельного представления является элемент choice
. Например, в тексте Лукреция вы можете увидеть такое:
sic calor atque <choice><reg>aer</reg><orig>aër</orig></choice> et venti caeca potestas
Здесь reg
указывает на нормализованное написание, а orig
– на оригинальное.
Для исправления ошибок используются элементы <sic>
(«так у автора») и <corr>
(«исправленное написание»):
<choice>
<sic>вихремъ</sic>
<corr resp="#editor1">верхомъ</corr>
</choice>
Атрибут resp
содержит ссылку на идентификатор редактора.
6.2.4 Структурная разметка
TEI предоставляет богатый набор элементов для разметки структуры текста:
<text>
— текст целиком<body>
— основное содержание текста<div>
— структурное деление (глава, часть, раздел)<p>
— параграф<l>
— стихотворная строка<lg>
— группа стихотворных строк (строфа)<sp>
— речь персонажа в драме<stage>
— ремарка
Пример разметки поэзии:
<lg type="quatrain">
<l met="+-|+-|+-|+-">Дар напрасный, дар случайный,</l>
<l met="+-|+-|+-|+">Жизнь, зачем ты мне дана?</l>
<l met="+-|+-|+-|+-">Иль зачем судьбою тайной</l>
<l met="+-|+-|--|+">Ты на казнь осуждена?</l>
</lg>
6.3 Кейс: “Горе от ума”
Скачаем по из репозитория проекта Dracor “Горе от ума” Грибоедова и преобразуем xml в прямоугольный формат таким образом, чтобы для каждой реплики был указан акт, сцена и действующее лицо.
<- "https://raw.githubusercontent.com/dracor-org/rusdracor/main/tei/griboyedov-gore-ot-uma.xml"
url download_xml(url, file = "griboedov.xml")
<- read_xml("../files/griboedov.xml")
doc
# определить пространство имён
<- xml_ns(doc)
ns ns
d1 <-> http://www.tei-c.org/ns/1.0
Пространство имён (namespace) в XML — это механизм, который позволяет однозначно различать элементы и атрибуты с одинаковыми именами, но из разных словарей или стандартов. Оно действует как “фамилия” для элемента. Чтобы задать “фамилию”, её связывают с уникальным идентификатором (обычно это URI, в нашем случае http://www.tei-c.org/ns/1.0). Для удобства этому идентификатору присваивают короткий префикс.
# Найти все строки (tei:l)
<- xml_find_all(doc, ".//d1:l", ns)
line_nodes
# Извлечь текст каждой строки
<- xml_text(line_nodes)
line_text |>
line_text head()
[1] "Светает!.. Ах! как скоро ночь минула!"
[2] "Вчера просилась спать — отказ."
[3] "«Ждем друга». — Нужен глаз да глаз,"
[4] "Не спи, покудова не скатишься со стула."
[5] "Теперь вот только что вздремнула,"
[6] "Уж день!.. сказать им..."
Теперь нам надо для каждой реплики найти информацию о том, кто говорит: она хранится в теге <speaker>
. То есть нам надо подняться на два этажа вверх (на уровень <sp>
), а потом спуститься к его другому “ребенку”, <speaker>
. Для этого используем синтаксис XPath: сначала при помощи ancestor::d1:sp
поднимаемся вверх по дереву и выбираем всех предков узла, которые являются элементами sp
, а затем спускаемся к ребенку speaker
этого найденного sp
. Так список спикеров будет равно числу стихов.
# line_nodes — вектор узлов <l>
<- xml_text(
speakers xml_find_first(line_nodes, "ancestor::d1:sp/d1:speaker", ns = ns)
)
|>
speakers head()
[1] "Лизанька" "Лизанька" "Лизанька" "Лизанька" "Лизанька" "Лизанька"
Аналогичным образом находим явление и акт.
<- xml_text(
scenes xml_find_first(line_nodes, "ancestor::d1:div[@type='scene']/d1:head", ns = ns)
)
|>
scenes tail()
[1] "Явление 15" "Явление 15" "Явление 15" "Явление 15" "Явление 15"
[6] "Явление 15"
<- xml_text(
acts xml_find_first(line_nodes, "ancestor::d1:div[@type='act']/d1:head", ns = ns)
)
|>
acts sample(6)
[1] "Действие четвертое" "Действие второе" "Действие второе"
[4] "Действие четвертое" "Действие третье" "Действие третье"
Нам осталось объединить все векторы в одну таблицу.
<- tibble(
woe_from_wit act = acts,
scene = scenes,
speaker = speakers,
text = line_text
)
|>
woe_from_wit head(6) |>
::gt() gt
act | scene | speaker | text |
---|---|---|---|
Действие первое | Явление 1 | Лизанька | Светает!.. Ах! как скоро ночь минула! |
Действие первое | Явление 1 | Лизанька | Вчера просилась спать — отказ. |
Действие первое | Явление 1 | Лизанька | «Ждем друга». — Нужен глаз да глаз, |
Действие первое | Явление 1 | Лизанька | Не спи, покудова не скатишься со стула. |
Действие первое | Явление 1 | Лизанька | Теперь вот только что вздремнула, |
Действие первое | Явление 1 | Лизанька | Уж день!.. сказать им... |
6.4 Кейс: “Война и мир”
В качестве примера загрузим датасет “Пушкинского дома”, подготовленный Д.А. Скоринкиным: “Персонажи «Войны и мира» Л. Н. Толстого: вхождения в тексте, прямая речь и семантические роли”.
= "../files/War_and_Peace.xml"
filename <- read_xml(filename)
doc <- xml_ns(doc)
ns ns
d1 <-> http://www.tei-c.org/ns/1.0
<- xml_root(doc) rootnode
Корневой элемент расходится на две ветви. Полностью они нам пока не нужны, узнаем только имена:
xml_children(rootnode) |>
xml_name()
[1] "teiHeader" "text"
Очевидно, что что-то для нас интересное будет спрятано в ветке text, смотрим на нее и ее детей:
<- xml_find_all(rootnode, "//d1:text//d1:div", ns)
divs divs
{xml_nodeset (380)}
[1] <div n="1" type="volume" xml:id="Volume1">\n <div n="1" type="part" xml ...
[2] <div n="1" type="part" xml:id="part1Volume1">\n <div n="1" type="chapte ...
[3] <div n="1" type="chapter" xml:id="chapter1part1Volume1">\n <p/>\n <p>\ ...
[4] <div n="2" type="chapter" xml:id="chapter2part1Volume1">\n <p>\n </ ...
[5] <div n="3" type="chapter" xml:id="chapter3part1Volume1">\n <p>\n </ ...
[6] <div n="4" type="chapter" xml:id="chapter4part1Volume1">\n <p>\n </ ...
[7] <div n="5" type="chapter" xml:id="chapter5part1Volume1">\n <p>\n </ ...
[8] <div n="6" type="chapter" xml:id="chapter6part1Volume1">\n <p>\n </ ...
[9] <div n="7" type="chapter" xml:id="chapter7part1Volume1">\n <p>\n </ ...
[10] <div n="8" type="chapter" xml:id="chapter8part1Volume1">\n <p>\n </ ...
[11] <div n="9" type="chapter" xml:id="chapter9part1Volume1">\n <p>\n </ ...
[12] <div n="10" type="chapter" xml:id="chapter10part1Volume1">\n <p>\n ...
[13] <div n="11" type="chapter" xml:id="chapter11part1Volume1">\n <p>\n ...
[14] <div n="12" type="chapter" xml:id="chapter12part1Volume1">\n <p>\n ...
[15] <div n="13" type="chapter" xml:id="chapter13part1Volume1">\n <p>\n ...
[16] <div n="14" type="chapter" xml:id="chapter14part1Volume1">\n <p>\n ...
[17] <div n="15" type="chapter" xml:id="chapter15part1Volume1">\n <p>\n ...
[18] <div n="16" type="chapter" xml:id="chapter16part1Volume1">\n <p>\n ...
[19] <div n="17" type="chapter" xml:id="chapter17part1Volume1">\n <p>\n ...
[20] <div n="18" type="chapter" xml:id="chapter18part1Volume1">\n <p>\n ...
...
Обратите вниманию на разницу: при помощи одного слеша ищем только прямых потомков.
xml_find_all(rootnode, "//d1:text/d1:div", ns)
{xml_nodeset (5)}
[1] <div n="1" type="volume" xml:id="Volume1">\n <div n="1" type="part" xml: ...
[2] <div n="2" type="volume" xml:id="Volume2">\n <div n="1" type="part" xml: ...
[3] <div n="3" type="volume" xml:id="Volume3">\n <div n="1" type="part" xml: ...
[4] <div n="4" type="volume" xml:id="Volume4">\n <div n="1" type="part" xml: ...
[5] <div type="epilogue" xml:id="novel_epilogue">\n <div n="1" type="part" x ...
Вот так отбираем только уровень книги.
xml_find_all(rootnode, "//d1:text//d1:div[@type='volume']", ns)
{xml_nodeset (4)}
[1] <div n="1" type="volume" xml:id="Volume1">\n <div n="1" type="part" xml: ...
[2] <div n="2" type="volume" xml:id="Volume2">\n <div n="1" type="part" xml: ...
[3] <div n="3" type="volume" xml:id="Volume3">\n <div n="1" type="part" xml: ...
[4] <div n="4" type="volume" xml:id="Volume4">\n <div n="1" type="part" xml: ...
Так ищем книгу или эпилог:
xml_find_all(rootnode, "//d1:text//d1:div[@type='volume' or @type='epilogue']", ns)
{xml_nodeset (5)}
[1] <div n="1" type="volume" xml:id="Volume1">\n <div n="1" type="part" xml: ...
[2] <div n="2" type="volume" xml:id="Volume2">\n <div n="1" type="part" xml: ...
[3] <div n="3" type="volume" xml:id="Volume3">\n <div n="1" type="part" xml: ...
[4] <div n="4" type="volume" xml:id="Volume4">\n <div n="1" type="part" xml: ...
[5] <div type="epilogue" xml:id="novel_epilogue">\n <div n="1" type="part" x ...
Забрать конкретную главу можно по значению соответствующего атрибута. Извлечем только прямую речь Анны Павловны.
xml_find_first(rootnode, "//d1:text//d1:div[@xml:id='chapter1part1Volume1']", ns) |>
xml_find_all(".//d1:said[@who='Anna_Pavlovna_Scherer']", ns) |>
xml_attr("speech_text") |>
head()
[1] "- Eh bien, mon prince. Gênes et Lueques ne sont plus que des apanages, des поместья, de la famille Buonaparte. Non, je vous préviens que si vous ne me dites pas que nous avons la guerre, si vous vous permettez encore de pallier toutes les infamies, toutes les atrocités de cet Antichrist (ma parole, j'y crois) — je ne vous connais plus, vous n'êtes plus mon ami, vous n'êtes plus мой верный раб, comme vous dites. Ну, здравствуйте, здравствуйте. Je vois que je vous fais peur, садитесь и рассказывайте."
[2] "Вы весь вечер у меня, надеюсь?. Как можно быть здоровой... когда нравственно страдаешь?. Разве можно, имея чувство, оставаться спокойною в наше время?"
[3] "- Я думала, что нынешний праздник отменен. Je vous avoue que toutes ces t'êtes et tous ces feux d'artifice commencent à devenir insipides."
[4] "- Ne me tourmentez pas. Eh bien, qu'a-t-on décidé par rapport à la dépêche de Novosilzoff? Vous savez tout."
[5] "- Ах, не говорите мне про Австрию!"
[6] "A propos,. Сейчас. нынче у меня два очень интересные человека, le vicomte de Mortemart, il est allié aux Montmorency par les Rohans, одна из лучших фамилий Франции."
Подбробнее о структуре XML документов и способах работы с ними вы можете прочитать в книгах: (Nolan и Lang 2014) и (Холзнер 2004).