6  Разметка TEI XML

XML (от англ. eXtensible Markup Language) — расширяемый язык разметки. Слово “расширяемый” означает, что список тегов не зафиксирован раз и навсегда: пользователи могут вводить свои собственные теги и создавать так называемые настраиваемые языки разметки (Холзнер 2004, 29). Один из таких настраиваемых языков – это TEI (Text Encoding Initiative), о котором будет сказано дальше.

Для работы нам понадобятся следующие библиотеки:

library(tidyverse)
library(xml2)

6.1 Основы XML

Назначение языков разметки заключается в описании структурированных документов. Структура документа представляется в виде набора вложенных в друг друга элементов (дерева XML). У элементов есть открывающие и закрывающие теги.

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

В правильно сформированном XML открывающий и закрывающий тег вложенного элемента всегда находятся внутри одного родительского элемента.

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

string_xml <- '<?xml version="1.0" encoding="utf-8"?>
<!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.

doc <- read_xml(string_xml)
class(doc)
[1] "xml_document" "xml_node"    

Функция xml_root() позволяет извлечь корневой элемент вместе со всеми детьми.

rootnode <- xml_root(doc)
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. Выбрать узел по имени:
composition_node <- xml_find_first(rootnode, "composition")
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. Выбрать узел по индексу (например, второй дочерний узел):
composition_node <- xml_children(rootnode)[[2]]
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. Комбинировать выбор: второй узел -> первый элемент:
ingr_node_1 <- xml_find_first(composition_node, "ingredient")
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>
На заметку

В большинстве случаев функция требует задать пространство имен (namespace), но в нашем случае оно не определено, поэтому пока передаем только дерево и путь до узла. С пространством имен встретимся чуть позже!

6.1.5 От дерева к таблице

При работе с xml в большинстве случаев наша задача – извлечь значения определеннных узлов или их атрибутов и сохранить их в прямоугольном формате.

# Получаем узлы:
title <- xml_text(xml_find_all(rootnode, "title")) |> 
  trimws()

ingredient_ns <- xml_find_all(rootnode, "./composition/ingredient")

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 в прямоугольный формат таким образом, чтобы для каждой реплики был указан акт, сцена и действующее лицо.

На заметку

Для работы с корпусом Dracor в среде R существует пакет rdracor. Он позволяет доставать тексты пьес сразу в виде таблицы.

url <- "https://raw.githubusercontent.com/dracor-org/rusdracor/main/tei/griboyedov-gore-ot-uma.xml"
download_xml(url, file = "griboedov.xml")
doc <- read_xml("../files/griboedov.xml")

# определить пространство имён
ns <- xml_ns(doc)
ns
d1 <-> http://www.tei-c.org/ns/1.0

Пространство имён (namespace) в XML — это механизм, который позволяет однозначно различать элементы и атрибуты с одинаковыми именами, но из разных словарей или стандартов. Оно действует как “фамилия” для элемента. Чтобы задать “фамилию”, её связывают с уникальным идентификатором (обычно это URI, в нашем случае http://www.tei-c.org/ns/1.0). Для удобства этому идентификатору присваивают короткий префикс.

# Найти все строки (tei:l)
line_nodes <- xml_find_all(doc, ".//d1:l", ns)

# Извлечь текст каждой строки
line_text <- xml_text(line_nodes)
line_text |> 
  head()
[1] "Светает!.. Ах! как скоро ночь минула!"  
[2] "Вчера просилась спать — отказ."         
[3] "«Ждем друга». — Нужен глаз да глаз,"    
[4] "Не спи, покудова не скатишься со стула."
[5] "Теперь вот только что вздремнула,"      
[6] "Уж день!.. сказать им..."               

Теперь нам надо для каждой реплики найти информацию о том, кто говорит: она хранится в теге <speaker>. То есть нам надо подняться на два этажа вверх (на уровень <sp>), а потом спуститься к его другому “ребенку”, <speaker>. Для этого используем синтаксис XPath: сначала при помощи ancestor::d1:sp поднимаемся вверх по дереву и выбираем всех предков узла, которые являются элементами sp, а затем спускаемся к ребенку speaker этого найденного sp. Так список спикеров будет равно числу стихов.

# line_nodes — вектор узлов <l>
speakers <- xml_text(
  xml_find_first(line_nodes, "ancestor::d1:sp/d1:speaker", ns = ns)
)

speakers |> 
  head()
[1] "Лизанька" "Лизанька" "Лизанька" "Лизанька" "Лизанька" "Лизанька"

Аналогичным образом находим явление и акт.

scenes <- xml_text(
  xml_find_first(line_nodes, "ancestor::d1:div[@type='scene']/d1:head", ns = ns)
)

scenes |> 
  tail()
[1] "Явление 15" "Явление 15" "Явление 15" "Явление 15" "Явление 15"
[6] "Явление 15"
acts <- xml_text(
  xml_find_first(line_nodes, "ancestor::d1:div[@type='act']/d1:head", ns = ns)
)

acts |> 
  sample(6)
[1] "Действие четвертое" "Действие второе"    "Действие второе"   
[4] "Действие четвертое" "Действие третье"    "Действие третье"   

Нам осталось объединить все векторы в одну таблицу.

woe_from_wit <- tibble(
  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 Кейс: “Война и мир”

В качестве примера загрузим датасет “Пушкинского дома”, подготовленный Д.А. Скоринкиным: “Персонажи «Войны и мира» Л. Н. Толстого: вхождения в тексте, прямая речь и семантические роли”.

filename = "../files/War_and_Peace.xml"
doc <- read_xml(filename)
ns <- xml_ns(doc)
ns
d1 <-> http://www.tei-c.org/ns/1.0
rootnode <- xml_root(doc)

Корневой элемент расходится на две ветви. Полностью они нам пока не нужны, узнаем только имена:

xml_children(rootnode) |>  
  xml_name()
[1] "teiHeader" "text"     

Очевидно, что что-то для нас интересное будет спрятано в ветке text, смотрим на нее и ее детей:

divs <- xml_find_all(rootnode, "//d1:text//d1:div", ns) 
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).

Nolan, D., и D. T. Lang. 2014. XML and Web Technologies for Data Science with R. Springer.
Скоринкин, Даниил. 2016. «Электронное представление текста с помощью стандарта разметки TEI», 90–108.
Холзнер, Стивен. 2004. Энциклопедия XML. Питер.