Как создавать визуализации для книг
Поклонники веб-комикса xkcd, вероятно, помнят повествовательные диаграммы, которые он сделал для таких фильмов, как Властелин колец, Парк Юрского периода и — саркастически — 12 разгневанных мужчин:
Но большинство поклонников xkcd, вероятно, не знали, что существует проект программного обеспечения с открытым исходным кодом от Университета Ватерлоо для динамического создания этих диаграмм. Единственная проблема с этим проектом заключается в том, что он берет данные из комиксов, которые, я думаю, были созданы вручную. Мне было интересно применить их технику визуализации к книгам, поэтому я подключил код Python к их библиотеке d3, чтобы создавать повествовательные диаграммы для пьес и романов.
Археология данных
Первоначальный проект Ватерлоо отображал повествовательную диаграмму для панели комиксов за панелью. Информация поступала через объект JSON верхнего уровня, который содержал название произведения, список персонажей верхнего уровня с идентификаторами и список сцен с информацией о том, какие персонажи присутствовали в каждой из них:
{ "title": "Romeo and Juliet", "characters": [ { "name": "Romeo", "group": 1, "id": 1, }, // ... ], "scenes": [ { "title": "Act 1", "duration": 10, "start": 499, "chars": [1, 3, 7], "named_chars": ["Romeo", "Juliet", "Friar Laurence"], "id": 1, }, // ... ] }
Я хотел использовать этот инструмент для визуализации книг и пьес, таких как Ромео и Джульетта; таким образом, мне нужно было проанализировать тела этих работ и обработать их, чтобы они выглядели как приведенный выше JSON. К счастью — и сейчас странно об этом думать — эти пьесы 16-го века были написаны на высокоструктурированном DSL, который выглядел так:
ACT I SCENE I. A public place. Enter Sampson and Gregory armed with swords and bucklers. SAMPSON. Gregory, on my word, we’ll not carry coals. GREGORY. No, for then we should be colliers. SAMPSON. I mean, if we be in choler, we’ll draw. GREGORY. Ay, while you live, draw your neck out o’ the collar. SAMPSON. I strike quickly, being moved.
Как будто Шекспир использовал формат файла, оканчивающийся на .play
. Границы акта и сцены четко обозначены, а строкам для каждого персонажа предшествует его имя в верхнем регистре плюс точка. Мы могли бы даже написать для этого грамматический файл:
CHARACTER := [A-Z ]+ LINE := CHARACTER ".\n" ([a-ZA-Z0-9 ,\"\'\?]+\n)*[a-ZA-Z0-9 ,\"\'\?]+ "\n" STAGE_DIRECTION := ([a-ZA-Z0-9 ,\"\'\?]+\n)*[a-ZA-Z0-9 ,\"\'\?]+ "\n" SCENE := "SCENE " [IVX]+ ".\n" (LINE | STAGE_DIRECTION)+
Однако для разбора этих игровых сценариев мы не собираемся писать полный парсер с yacc — это было бы излишеством. Но мы будем делать простой обход строк текста в духе алгоритма с одним указателем.
Парсинг — это все, что вам нужно
Мы собираемся получить игру в виде списка строк, а затем использовать алгоритм с одним указателем, в котором мы пошагово перемещаемся по строкам, используя индекс i = 0
. Мы хотим отслеживать, в какой сцене мы находимся, сколько персонажей в каждой сцене и кто какие строки произнес, как показано ниже:
import re lines = open(filename, 'r').readlines() scenes = [] last_line_ix = 0 last_scene_match = None next_scene_match = None i = 0 while i < len(lines) line = lines[i] next_scene_match = re.match(r"SCENE [IVX]+", line) if next_scene_match: # Save the last scene with all its lines. if last_scene_match: scenes.append( Scene( title=last_scene_match.group(0), lines=lines[last_line_ix : i]) last_scene_match = next_scene_match last_line_ix = i + 1 i += 1 if len(lines) > last_line_ix: scenes.append( Scene( title=last_scene_match.group(0), lines=lines[last_line_ix : len(lines)])
Каждый раз, когда мы видим новый маркер "SCENE "
, мы берем все строки, которые были видны с момента последнего маркера сцены, и склеиваем их в объект Scene
. Единственная проблема с подобным синтаксическим анализом заключается в том, что вы обычно выходите из основного цикла с некоторым «зависанием». Поскольку цикл сохраняет последнюю сцену только после того, как он увидит новый маркер сцены, а воспроизведение не заканчивается конечным маркером сцены, мы должны не забыть сохранить последнюю сцену после выхода из цикла — это проверка if len(lines) > last_line_x:
.
Один важный нюанс парсеров заключается в том, что они должны работать только в одном направлении. Из всех просмотров кода, которые я когда-либо делал, тот, в котором я, вероятно, принёс наибольшую пользу для карьеры инженера, был тот, в котором я просматривал такой код:
i = 0 do_other_thing = False while i < len(lines): line = lines[i] if do_other_thing: option_b(line) i += 1 do_other_thing = False elif something_else(line): # Oh, we should have treated the last line differently, so go back. do_other_thing = True i -= 1 else: option_a(line) i += 1
Этот код начинался как обычный анализатор с одним указателем с индексом i
, который перемещался по списку. Но затем программист обнаружил пограничный случай, который означал, что им нужно было «вернуться и исправить ситуацию», поэтому они установили флаг do_other_thing = True
и уменьшили указатель на i -= 1
. Затем в следующем цикле флаг заставил синтаксический анализатор выполнить специальное действие.
Проблема с этим небольшим изменением заключалась в том, что оно нарушает монотонность. Если все, что делает синтаксический анализатор, — это увеличивает i += 1
от начала до конца, то программа гарантированно завершится. Однако, как только он имеет декремент i -= 1
, синтаксический анализатор становится двунаправленным, поток управления намного сложнее и потенциально может закончиться бесконечным циклом. И в этом коде тоже есть баг — должно быть i += 2
вместо i += 1
в ветке if do_other_thing:
. В противном случае он просто нажмет something_else
на следующем цикле и снова пойдет назад, что приведет к такому же бесконечному циклу.
Гораздо лучшая версия этого кода использует «упреждающий просмотр» или просмотр следующих строк всякий раз, когда требуется дополнительная информация. Это гораздо более чистая версия вышеприведенного, в которой используется «просмотр вперед» вместо «перемотки назад». Вот как выглядит код:
i = 0 while i < len(lines): line = lines[i] if i + 1 < len(lines) and something_else(lines[i + 1]): # Treat these lines differently. option_b(line) option_b(lines[i + 1]) i += 2 else: option_a(line) i += 1
Визуализация результатов
После того, как я проанализировал все сцены, я просто отправил им список персонажей и псевдонимов для поиска (например, "ROMEO."
и "JULIET."
), чтобы отслеживать, кто появляется в каждой сцене:
class Scene(object): def __init__(self, title=None, lines=None): self.title = title self.lines = lines self._character_occs = {} def FindCharacters(self, characters): for line in self.lines: for character in characters: for alias in character.aliases: if alias in line: self.AddCharacter(character) def AddCharacter(self, character): if character.name not in self._character_occs: self._character_occs[character.name] = { 'character': character, 'count': 1, } else: self._character_occs[character.name]['count'] += 1
Последним шагом был перевод этого в формат, ожидаемый кодом Ватерлоо (который был немного сложным и не интересным для этой статьи), а затем рендеринг на веб-странице шаблона. Для Ромео и Джульетты наша повествовательная таблица выглядит следующим образом:
Приятно видеть, как повествования персонажей переплетаются воедино: основная связь собирается вокруг кончины Тибальта, Ромео и Джульетта порхают друг вокруг друга до своей роковой кончины, а брат Лоуренс просто тайно руководит всем сюжетом снизу.
Я также хотел обобщить этот код для обработки других работ, поэтому я завернул его в скрипт, который принимает аргументы. Синтаксический анализ Ромео и Джульетты можно выполнить, указав входной файл, регулярное выражение сцены и группы символов следующим образом:
./novel_narrative_charts.py \ --book \ --title="Romeo and Juliet" \ --chapter_regex="SCENE \\w+\\." \ --filename=sources/romeo_and_juliet.txt \ --character_group=CAPULET.\|LADY\ CAPULET.,TYBALT. \ --character_group=MONTAGUE.\|LADY\ MONTAGUE.,BENVOLIO. \ --character_group=PARIS.,MERCUTIO.,FRIAR\ LAWRENCE. \ --character_group=JULIET.,ROMEO.
С этой оболочкой легко применить этот код к другим произведениям — и не только к играм. Чтобы разобрать один из моих любимых романов, Миддлмарч, я просто передал ему новый набор аргументов (хотя для романа этот скрипт в настоящее время не может определить разницу между тем, является ли отсылка к персонажу имя означает, что они говорят, появляются или просто говорят о них):
./novel_narrative_charts.py \ --book \ --title="Middlemarch" \ --chapter_regex="CHAPTER \\w+\\." \ --filename=sources/middlemarch.txt \ --character_group=Dorothea\ Brooke\|Dorothea\|Miss\ Brooke,Celia\ Brooke\|Celia,Arthur\ Brooke\|Arthur\|Mr\.\ Brooke \ --character_group=Mary\ Garth\|Mary\|Miss\ Garth,Caleb\ Garth\|Caleb\|Mr\.\ Garth \ --character_group=Tertius\ Lydgate\|Tertius\|Lydgate,James\ Chettam\|James,Will\ Ladislaw\|Will\|Mr.\ Ladislaw \ --character_group=Rosamond\ Vincy\|Rosamond\|Miss\ Vincy\|Mrs\.\ Lydgate,Fred\ Vincy\|Fred,Mr\.\ Casaubon\|Casaubon
Полученная диаграмма, однако, становится довольно занятой, и я, возможно, расширяю пределы этой технологии как есть (или, может быть, мне следует выбрать более короткую книгу для анализа):