Документ и представление: модель просмотрщика
Оглавление серии «Документы в вебе» · Урок 1 из 10.
На сайте могут храниться инструкции, договоры, конспекты и отчёты. Читателю удобно открыть документ прямо в браузере, перейти к нужной странице и оставить заметку. Однако PDF устроен иначе, чем HTML-статья. Прежде чем выбирать библиотеку, определим, что именно будет делать наш просмотрщик «Лист» и какие данные принадлежат ему, а какие остаются внутри документа.
Начнём с двухстраничного учебного файла. На этом шаге подготовим его генератор и HTML-каркас приложения. Изображение страниц появится в следующем уроке. Такая последовательность позволяет сначала понять границы проекта: мы создаём собственный интерфейс чтения, а не полноценный редактор всех возможностей PDF.
Документ сохраняет страницу
HTML описывает структуру содержимого. Браузер подбирает расположение строк по ширине окна, шрифтам и стилям. PDF обычно сохраняет геометрию уже подготовленных страниц. Если исходный документ рассчитан на лист определённого размера, узкий экран не превратит его автоматически в удобную мобильную статью. Просмотрщик может уменьшить изображение или предложить прокрутку, но это ещё не изменение структуры самого материала.
Представьте методичку с таблицей и подписью к иллюстрации. В HTML таблица может получить отдельную горизонтальную прокрутку, а подпись останется под рисунком. При уменьшении целой страницы PDF одновременно уменьшаются таблица, рисунок и текст. Поэтому масштаб нужно рассматривать как инструмент чтения конкретного листа. Для длинной учебной статьи разумно сохранять и обычную HTML-версию, даже если читателю предлагается PDF для печати.
У PDF есть несколько разных представлений. Бинарный файл содержит объекты документа. Растровая поверхность показывает внешний вид страницы. Извлечённый текст помогает искать слова и читать материал другим способом. Эти представления связаны, но не равнозначны. Изображение может выглядеть правильно, пока текстовый поиск ничего не находит: например, когда страница представляет собой скан без распознанных символов.
В «Листе» будем явно разделять исходный документ, текущее представление страницы и пользовательские заметки. Исходный PDF остаётся неизменным. Номер страницы, масштаб и поворот принадлежат интерфейсу. Заметка связывается с определённой версией документа и страницей, но хранится в отдельном JSON. Позднее это разделение позволит экспортировать комментарии без обещания, что они встроены в PDF или видны в любом стороннем просмотрщике.
| Сущность | Что хранит | Что не заменяет |
|---|---|---|
| Исходный PDF | Байты и структуру страниц | Учёт прав пользователя |
| Состояние просмотра | Номер страницы, масштаб, поворот | Содержимое документа |
| Текст страницы | Извлечённые символы | OCR для скана |
| Заметка | Страницу и собственный текст | Подпись или правку PDF |
Подготовим собственный образец
Для учебного проекта нужен предсказуемый документ. Чужой файл может содержать неожиданные шрифты, формы, вложения или личные данные. Напишем маленький генератор, который создаёт две страницы с несколькими английскими строками. Английский текст выбран потому, что этот узкий образец использует стандартный Helvetica и ASCII. Поддержка русских шрифтов потребовала бы отдельного разбора кодирования и встраивания шрифтов.
Ниже полный файл make_sample.py. Это авторский генератор конкретного образца, а не универсальный инструмент создания произвольных PDF. Его можно сохранить рядом с index.html; при самостоятельном выполнении он должен записать assets/study.pdf.
from pathlib import Path
def literal(text):
return text.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
pages = [
['Study Document', 'Canvas is an image.', 'Text has its own reading order.'],
['Workshop Notes', 'A note belongs to a document and a page.', 'Export notes separately.'],
]
objects = [b'<< /Type /Catalog /Pages 2 0 R >>',
b'<< /Type /Pages /Kids [4 0 R 6 0 R] /Count 2 >>',
b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>']
for index, lines in enumerate(pages):
content_number = 5 + index * 2
objects.append((f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] '
f'/Resources << /Font << /F1 3 0 R >> >> '
f'/Contents {content_number} 0 R >>').encode('ascii'))
commands = 'BT /F1 18 Tf 50 780 Td 28 TL\n'
commands += '\n'.join(f'({literal(line)}) Tj T*' for line in lines) + '\nET\n'
stream = commands.encode('ascii')
objects.append(f'<< /Length {len(stream)} >>\nstream\n'.encode('ascii')
+ stream + b'endstream')
output = bytearray(b'%PDF-1.4\n')
offsets = [0]
for number, body in enumerate(objects, 1):
offsets.append(len(output))
output += f'{number} 0 obj\n'.encode('ascii') + body + b'\nendobj\n'
xref = len(output)
output += f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode('ascii')
for offset in offsets[1:]:
output += f'{offset:010d} 00000 n \n'.encode('ascii')
output += (f'trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\n'
f'startxref\n{xref}\n%%EOF\n').encode('ascii')
Path('assets').mkdir(exist_ok=True)
Path('assets/study.pdf').write_bytes(output)
Обратите внимание на два разных номера. В массиве Python страницы идут с нулевого индекса, но ссылки объектов PDF имеют собственную нумерацию. Корневой объект указывает на дерево страниц; дерево содержит ссылки на два объекта страниц. Каждая страница ссылается на свой поток команд и общую запись шрифта. Внешний номер страницы, который увидит читатель, появится уже в интерфейсе библиотеки.
Список offsets фиксирует позиции объектов в готовом массиве байтов. Позиция должна измеряться именно в байтах, а не в количестве символов строки. В нашем ASCII-образце эти величины совпадают, но при другом кодировании такое совпадение нельзя предполагать. Таблица xref и поле startxref связывают структуру файла с записанными позициями. Если изменить содержимое после вычисления смещений, ссылки на объекты могут стать неверными.
Функция literal экранирует скобки и обратную косую черту внутри строковых литералов нашего образца. Она не превращает произвольный пользовательский ввод в безопасный или корректный PDF для всех случаев. Генератор получает только заданные в исходнике строки и кодирует их как ASCII. Попытка добавить кириллицу остановится при кодировании, что полезнее скрытого повреждения текста. Для настоящих отчётов позднее выбирают библиотеку с подходящей поддержкой шрифтов.
Отделим интерфейс от файла
HTML-каркас находится в index.html. На первом шаге он содержит название, пояснение и область будущего изображения. У canvas есть понятная подпись, но она не представляет полный текст двух страниц. Это различие важно: доступность нельзя получить одним атрибутом, если существенное содержимое осталось только в пикселях. В пятом уроке добавим отдельное текстовое представление.
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Лист — учебный просмотрщик</title><link rel="stylesheet" href="style.css"></head>
<body><header><h1>Лист</h1><p>Учебный документ и отдельные заметки к его страницам.</p></header>
<main><p id="status" role="status">PDF подключим в следующем уроке</p>
<div id="surface"><div id="frame"><canvas id="page" aria-label="Изображение текущей страницы PDF"></canvas></div></div>
<article id="print-sheet"></article></main>
</body></html>
Файл документа поместим в assets, а код интерфейса будет жить рядом с HTML. Пользовательская заметка впоследствии не станет ещё одним файлом в этой папке: браузер не может незаметно перезаписывать произвольный путь на диске. Для сохранения заметок понадобится локальное хранилище либо специально спроектированный серверный API. Сейчас сервера приложения и аккаунтов у нас нет.
Полезно заранее договориться об исходных условиях. Учебный документ содержит две страницы, обычный текст и один стандартный шрифт. В нём нет пароля, цифровой подписи и формы. Отсутствие этих свойств упрощает первый шаг, но не означает, что будущая библиотека автоматически обеспечит их полную поддержку. Если ваш продукт должен проверять подписи или редактировать формы, это отдельные результаты и отдельные проверки.
Что должно получиться
После самостоятельного выполнения генератора должен появиться небольшой study.pdf. При открытии файла подходящим PDF-просмотрщиком ожидаются страницы «Study Document» и «Workshop Notes». На HTML-странице пока остаётся каркас «Листа». Пустой canvas на этом этапе соответствует плану, поскольку JavaScript-библиотека ещё не подключена. Мы не выдаём этот ожидаемый результат за выполненную проверку генератора.
Следующий шаг соединит существующий документ с первым изображением страницы. Для этого понадобится библиотека, которая умеет читать структуру PDF и строить изображение средствами браузера. PDF.js предоставляет такой механизм. Наш интерфейс будет определять, когда читать файл, какую страницу показывать и как объяснять ошибку пользователю; библиотека возьмёт на себя разбор формата и отрисовку.
Такое разделение помогает и в дальнейшем развитии. Можно заменить учебный документ, сохранив интерфейс; можно изменить интерфейс, сохранив исходные байты; можно экспортировать заметки, не изменяя ни того, ни другого. Но при каждой замене нужно знать, какую именно версию документа читатель просматривает. К этому вопросу вернёмся, когда начнём сохранять его собственные записи.
Полные файлы этого шага находятся в папке lesson-01 архива «Лист». Учебный просмотрщик, библиотека и генератор PDF при подготовке не запускались; результаты выше ожидаются из разбора кода.