Задача была поставлена так: «Нужно создать приложение, в которое можно загружать PDF-файлы, а затем выделять и сохранять в них произвольные куски текста, превращая их в аннотации».
Таким образом, выделив нужные куски, мы потом могли бы отображать списком выдержки из разных PDF-документов в форме линейного списка. Все эти аннотации могут быть сгруппированы и относиться к определенной теме (это, например, полезно в юриспруденции или научных исследованиях).
Изначально формат PDF был придуман для того, чтобы отображать полиграфическую продукцию в электронном виде. Первая версия стандарта была предложена в 1993 году, она была достаточно примитивна.
Со временем формат PDF сильно усложнился, добавилось множество фич, вплоть до возможности воспроизведения аудио, видео, 3D-содержимого, а также выполнения программного кода. Текст, описывающий формат — это PDF-документ размером в 700-800 страниц.
Узнав об этом в одной из записей выступления разработчиков браузерной PDF-читалки, о которой далее, я понял что все очень и очень непросто. Мысль о возможном написании чего-то подобного с нуля сразу отпала. По крайней мере, не в этой жизни =)
Как устроен PDF?
Давайте сначала поймем что из себя представляет формат PDF внутри файла с одноименным расширением.
Итак, в самом начале находится заголовок (Header), содержащий в себе всяческую информацию, в том числе, версию формата, использованного в этом документе.
Далее идет тело документа (body). Это последовательность объектов, каждый из которых имеет свой уникальный идентификатор. Объектами являются слова, шрифты, команды для рисования геометрических фигур, изображения, закладки, формы.. и много чего еще.
Затем идет так называемая xRefTable или иначе Cross-reference table, в которой записаны связи между идентификаторами объектов и их байтовыми смещениями относительно начала файла. Таким образом, чтобы найти требуемый объект, не нужно последовательно читать файл с самого начала. Можно пойти в таблицу и просто найти смещение и зная длину объекта, считать его из памяти.
В самом низу этой схемки размещается трейлер (Trailer), являющийся точкой входа, root-объектом из которого можно также узнать смещение xRefTable. И там же находятся все страницы документа.
Чем открывать PDF в браузере?
Рассмотрим возможные решения, каким образом мы сможем открывать PDF в браузере, используя для всех необходимых процедур только HTML5-технологии и JavaScript. По правде сказать, есть только одна реальная бесплатная вещь — это PDF.js (https://github.com/mozilla/pdf.js). Разработка началась в 2011 году руками разработчиков из Mozilla, однако сейчас особых планов по ее развитию нет и в основном лишь патчатся какие-то баги.
Есть еще PSPDFKit (https://pspdfkit.com/), которая позиционируется как мощная альтернатива PDF.js, которая умеет все, что могут лучшие десктопные читалки вроде Adobe PDF Reader. Причем, судя по документам на оф.сайте, он может то, что нужно для выполнения поставленной по проекту задачи, а именно — выделять текст. Но демка не работает, цен нет, про условия использования тоже не ясно. Хоть сайт сделан приятно, проект выглядит мертвым. Короче говоря — остается только PDF.js.
PDF.js это библиотека, работающая на JavaScript (соответственно, она может работать как в браузере, так и на бэкэнде, в среде Node.js)
Учитывая что через PDF можно распространять даже вирусы (а как я уже упоминал, согласно стандарту, в PDF-документы можно вставлять пользовательские скрипты), запуск PDF в среде браузера это некая гарантия защищенности, потому что каждая веб-страница это песочница, без прямого доступа к системе пользователя.
Теперь поговорим про то, как происходит парсинг и отрисовка документа внутри PDF.js.
Для начала, делается XHR2-запрос (читай: кроссдоменный), в результате которого мы получаем в ответ массив байт (см. конструктор Uint8Array в браузере), который дальше уже можно интерпретировать согласно предписаниям формата PDF. Работа с массивом идет как со стримом (Streams API), то есть можно читать последовательно по мере получения данных, без необходимости ждать полной загрузки. В этом, собственно, основной смысл стримов как таковых.
Следующий этап, это создание стрима PDFDoc, который связан с самим документом. Считывается xRef-таблица и root-объект из трейлера. И потом из PDFDoc методом getPage(pageNumHere) берется нужная по счету страница. Далее, мы рендерим полученную страницу, переводя команды формата PDF в вызовы функций, которые смогут сделать то же самое в среде браузера.
Однако, перед самой отрисовкой все связанные объекты (шрифты, изображения), должны быть подгружены, иначе при попытке их вывести мы получим пустоту. Это отличается от поведения браузера, который при неподгруженном шрифте применяет какой-то стандартный. Подбирая его максимально близко к кастомному, мы уменьшаем эффект прыжка, когда файл шрифта все-таки будет загружен.
Кстати говоря, существует два репозитория PDF.js. Первый — https://github.com/mozilla/pdf.js — содержит в себе все исходники, а во втором — https://github.com/mozilla/pdfjs-dist — находится собранная версия, для использования в проектах. Это чтобы вы не запутались.
Прикручиваем PDF.js к React
Для исполнения проекта был выбран React, как наиболе подходящий инструмент для написания SPA (single page application, одностраничное приложение). Но для начала я решил запустить рендер документа исключительно через библиотеку PDF.js. Выяснилось, что он выдает только изображение PDF-документа на Canvas. Соответственно, чтобы делать аннотации, необходимо каким-то образом создать текстовое представление документа, чтобы с ним можно было взаимодействовать.
Рендеринг PDF — действительно непростая вещь, в разработку которой программисты PDF.js вложили огромные усилия
Например, изображения могут быть JPEG-стримами, которые легко конвертировать в base64-представление и вставить в поле src HTML-элемента <img>. Но изображение может быть и, скажем, черно-белым. Тогда его необходимо перевести из Grayscale в цветовое пространство RGB и уже потом рисовать на канве.
Со шрифтами вообще беда, потому что их форматов существует гораздо больше, чем нам известно по миру веб-разработки. Внутри PDF.js все форматы приводятся к OpenType, внедряются на HTML-страницу через CSS, и как только они загружены, можно начинать рендеринг PDF-документа. Ждать полной загрузки надо по уже описанной выше причине. Интересно, что в формате PDF некоторые пары символов могут рисоваться как один глиф для достижения наилучшего с точки зрения типографики результат. Для этого нужно подключать так называемые cmaps (поставляются с PDF.js).
Однако, некоторые шрифты все же не могут быть сконвертированы в OpenType. И тут мы приходим к недостаткам PDF.js. Нет возможности легко и просто отследить, что шрифт был загружен и можно начинать рендеринг. Приходится прибегать к различным хакам, чтобы это понять. Например, наблюдать за изменением размеров блока с текстом и использовать разные другие трюкачества. Сами глифы отрисовываются и позиционируются в браузере немного не так, как должны быть.
Текст выделить при рисовании на canvas’е нельзя. Печатать проблематично, потому что canvas-представление при выводе на печать даст не настолько четкий результат, как хотелось бы. Производительность такого подхода не так хороша как в нативных реализациях. Используются web-воркеры для рендеринга.
Хотя, надо сказать, что в целом работает библиотека довольно шустро. Ну и поддержки всех возможностей формата PDF нет. Только самое основное и нужное. Например, PDF-форм и 3D-графики тут нет. Но лично мне такое и не нужно. Думаю, многим разработчикам хотелось бы видеть нативную поддержку выделения текста на уровне одной библиотеки, чтобы не нужно было для этого подключать что-то дополнительно.
В экосистеме React’а существует пакет react-pdf (https://github.com/wojtekmaj/react-pdf), который как раз этим и занимается. Он выводит прямо поверх канвы HTML-элементы с текстом, используя информацию, полученную при рендеринге, а именно: текст блоков, тип и размеры шрифта, абсолютное позиционирование относительно страницы и прочее. Получается имитация того, что документ изначально имеет реальный текст, который можно выделять и копировать. Делать это PDF.js может используя DIV- или SVG-блоки.
Разумеется из-за различий в рендеринге шрифтов и расчете расстояний, будут несовпадения между текстом в браузере и текстом на канве. Именно поэтому HTML-элементы с текстом имеют нулевую непрозрачность — чтобы не было «двоения в глазах».
На этом, первую часть статьи я закончу. Впереди еще довольно много интересных задач, которые предстоит решить и об этом мы поговорим в следующих частях.