Pull to refresh

PDF-принтер Хабра с подсветкой кода на Python

Reading time 6 min
Views 1.8K
На написание данной программы (а в последствии и статьи) меня сподвиг вот этот пост. Так уж вышло, что я имею привычку по-возможности сохранять прочитанные статьи, поскольку все помнить невозможно, и неизвестно когда что может пригодиться. Так что, прочитав вышеупомянутый пост и вспомнив про столь дорогую мне возможность печатать в PDF страницы из Википедии, закономерно появилась мыслишка сделать такой же «принтер» для Хабра, чтоб иметь возможность заполучить в личный архив вызвавшие у меня интерес статьи.

Первой попыткой было использование столь любезно предоставленной автором поста-вдохновителя программы. И практически сразу нашлись грабли, которые игнорировать было выше моих сил. Грабли эти — подсветка кода.

Сразу оговорюсь, на Хабре я новичок и как что работает имею очень смутное понятие. Однако взглянув на исходник страницы со статьей, в которой представлен фрагмент кода, стал понятен источник проблемы. И он *барабанная дробь* в том, что раскраской кода занимается JavaScript. Нет, для чтения через браузер это конечно хорошо и круто, но питоновская pisa, которая и занимается отрисовкой страницы в PDF, код раскраски выполнить не может в принципе.

Возникла идея — надо что-то придумать.

Нужно модифицировать исходный код статьи таким образом, чтобы основные конструкции языка, код на котором представлен в статье, обрамлялись специальными html-тегами. Тогда достаточно добавить несколько строчек в css, чтобы увидеть код с подсветкой синтаксиса, которую можно увидеть как в браузере, так и в напечатанном pisa PDF. Что для этого нужно? Прежде всего, выделить те самые лексемы языка. И вот тут начинается веселье. Ну не делать же полноценный синтаксический анализатор для каждого языка! Однако, это и не потребовалось.

Вспомним теорию. Языки программирования, как правило, принадлежат к классу языков, описываемых контекстно-независимыми грамматиками, которые включают в себя как подмножество регулярные грамматики. Регулярные грамматики описывают базовые элементы языка — лексемы, из которых строятся все остальные синтаксические конструкции. Любимая всеми подсветка кода занимается тем, что выделяет некоторые типы лексем другим цветом, благодаря чему код становится читать проще и приятнее. Значит, задача сводится к следующему: составить регулярные выражения для всех классов подсвечиваемых лексем, найти все совпадения по регулярным выражениям и обрамить их в соответствующий html-тег. Однако проблема вот в чем: составление длинного регулярного выражения требует времени и сил. Для каждого языка таких выражений несколько. А языков много. Да и сами выражения не так просты, как может показаться. Например, попробуем определить регулярное выражение, соответствующего типу данных языка C (ограничимся несколькими, так как их много). Что тут сложного? Блин первый:
r'int|short|long|char'

Правильно? Нет. Такое регулярное выражение найдет совпадение, например, в строчке chelintano, и мы получим подсветку в середине слова. Выход очевиден — добавим пробельные символы в начале и в конце
r'\s+(int|short|long|char)\s+'

Опять неверно. Перед типом может стоять круглая скобка, квадратная скобка, фигурная скобка, а если вспомнить, что имя типа может означать операцию приведения типов — вообще куча всего. Получается, проще сказать, что перед именем типа стоять не может — буква, цифра и символ подчеркивания. Так что в результате получаем вот такую регулярку:
r'(?P&ltprefix>^|[^a-zA-Z0-9_])(?P&ltbody&gtint|short|long|char)(?P&ltpostfix>$|[^a-zA-Z0-9_])'

А теперь представьте такую мороку для каждого класса лексем. Для каждого языка. Вот было бы славно, если бы указать только основу, а регулярка составилась бы сама. И это можно сделать.

Мне наиболее удачным показалось решение записывать классы лексем языка в файле со структурой наподобие INI-файла. Для каждого класса лексем можно выделить основные составляющие: префикс — символы (последовательности символов), которые могут стоять перед лексемой, тело лексемы, и постфикс — символы, которые могут стоять после лексемы. Каждая составляющая, в свою очередь, может состоять из простых выражений — обычных строк, таких как int или function, либо из регулярных выражений, например, [0-9]+(\.[0-9]+)? (регулярное выражение для числа с плавающей запятой). Таким образом, в каждом блоке INI-файла могут задаваться следующие параметры:

lexem
before
after
regexpr

Значение параметра regexpr — регулярное выражение. Этот параметр можно использовать несколько раз, тогда результирующее регулярное выражение будет совпадать со всеми значениями параметра. Для первых трех параметров значением, как правило, является множество (перечисление), которое в лучших традициях питона записывается в квадратных скобках, также как и список. Иногда удобно разделять значения специальным символом, для указания которого используется параметр delimiter (по умолчанию разделитель — пустая строка, т.е. кождый символ считается возможным телом регулярки). Данный параметр меняет символ-разделитель в пределах блока файла, пока не встретится другое определение значения параметра. Случается, что начало или конец строк в перечислении lexem повторяется, яркий пример этому — определение директив препроцессора языка C++ (#include, #define, #pragma). Чтоб не писать лишнего (а вдруг их реально много), можно указать значения параметров prefix и postfix. Эти значения будут добавлены к каждой строке в перечислении lexem в начало и в конец соответственно.
Приведу пример для того же C++

[classname]

delimiter=;
postfix=\s+
before=[class]
regexpr=[a-zA-Z_][a-zA-Z0-9_]*

eqstyle=typeword

[number]

delimiter=
regexpr=[0-9]+(\.[0-9]+)?
regexpr=0x[0-9a-fA-F]+
before={abcdefghijklmnopqrstuvwxyzABCDEFJHIJKLMNOPQRSTUVWXYZ_}
after={abcdefghijklmnopqrstuvwxyzABCDEFJHIJKLMNOPQRSTUVWXYZ_}

Здесь можно заметить параметр eqstyle. Он введен исключительно из практических соображений чтобы не добавлять лишних записей при определении подсветки (в данном случае, тегов обертки и записей в файлах css). Определение параметра eqstyle нужно читать как «для этого класса лексем используйте все то же, что и для класса <значение>»

Дело за малым — прочитать этот файл, скомпилировать регулярное выражение для каждого класса лексемы. В результате получаем словарь с символьными ключами — именами классов лексем, и значениями — скомпилированными регулярными выражениями (назовем этот словарь «стилем» языка). Остается прогнать блок кода, опубликованного в желанной статье, через каждое из выражений «стиля».


def modify(style, eqstyles, stylename, block) :
	if style == None :
		return block
	# Преобразуем все служебные символы html в обычные печатные
	block = fromHTML(block)	
	wraps = []	
	
	for tag in style.keys() :
			_left = 0
			word = style[tag]
			if tag in eqstyles :
				tag = eqstyles[tag]
			m = word.search(block)
			while m != None :
				prefix = m.group('prefix')
				postfix = m.group('postfix')
				
				tag_left = _left + m.start() + len(prefix)
				tag_right = _left + m.end() - len(postfix)
				
				unique = True			# Флаг проверки, не является ли конструкция вложенной в другую, более широкую
				
				for (start, end, t) in wraps :
					if (tag_left <= start) and (tag_right >= end) :
						# Более широкий элемент грамматики перекрывает более узкий
						wraps.remove( (start, end, t) )
					elif tag_left >= start and tag_right <= end :
						# Данный грамматический символ вложен в другой, более широкий
						unique = False
						break
				if unique :
					wraps.append( (tag_left, tag_right, tag) )
				_left = _left + m.end()
				m = word.search(block[ _left : ])				
	wraps = sortByF(wraps, (lambda (x1,y1,z1), (x2,y2,z2) : x1 < x2))
	mod_block = ""
	_left = 0
	for w in wraps :
		mod_block = mod_block + block[_left : w[0]]
		mod_block = mod_block + ( "<%s_%s>%s</%s_%s>" % (stylename, w[2], toHTML(block[w[0] : w[1]]), stylename, w[2]) )
		_left = w[1]
	mod_block = mod_block + block[_left :]
	return mod_block 



Собственно, что здесь происходит. По каждому регулярному выражению происходит поиск первого совпадения. Так мы получаем смещение лексемы относительно начала, а также длину префикса и постфикса. В список совпадений добавляем тройку (<позиция начала лексемы>, <позиция конца лексемы>, <класс лексемы>). Позиция начала вычисляется как смещение совпадения относительно начала кода + длина префикса (для позиции конца аналогично). Попутно проверяется, не перекрывает ли лексема другие и не является ли перекрытой. Что это значит? Не стоит забывать, что лексемы — это просто строки, и одна строка может являться подстрокой для другой. И если эта самая другая тоже является лексемой, то она «перекрывает» вложенную. К примеру, если есть строка «int — это тип переменной для хранения целого четырехбайтного числа», слово int подсвечивать не надо, это просто часть строки — хотя оно и будет выделено как лексема. После обработки лексемы строка поиска усекается слева до позиции, в которой заканчивается найденная лексема, и производится новый поиск совпадения в оставшемся тексте, и так пока совпадения не кончатся.

Осталось самое простое — используя список позиций лексем, обрамить их html-тегами. Теги составляются просто: <«название языка»_«класс лексемы»>. В результате получаем блок кода, дополненный html-разметкой. Добавьте к этому определения стилей для каждого из этих тегов — и получите подсветку кода, которую одинаково успешно воспроизведет и браузер, и pisa.

Вот так выглядит одна из напечатанных страниц этой статьи. Блоки кода оформлены в стиле Obsidian, нагло позаимствованном из моего любимого Notepad++.



Представленный метод раскраски, определенно, не является идеальным ни с точки зрения полноты (иногда представленных параметров недостаточно для точного определения лексемы), ни с точки зрения производительности. Однако на отпечатанных мною статьях он значительных ляпов не давал. К тому же, не шатл в космос запускаем, здесь цена ошибки минимальна и оптимизировать смысла не вижу никакого. Если кто знает другие методы организации подсветки (я до этого, честно сказать, никогда не интересовался) — с удовольствием послушаю и прочту.

Программу можно загрузить отсюда

PS: Буду благодарен всем желающим помочь в написании описаний языков и стилей подсветки.
Tags:
Hubs:
+41
Comments 68
Comments Comments 68

Articles