Перейти к содержанию

Документ и представление: модель просмотрщика

Оглавление серии «Документы в вебе» · Урок 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 при подготовке не запускались; результаты выше ожидаются из разбора кода.