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

Печатное представление документа

Оглавление серии «Документы в вебе» · Урок 9 из 10.

В «Листе» есть изображение документа, извлечённый текст и отдельные заметки. Подготовим печатное представление текста вместе с заметками. Результат этого урока — обычный HTML-документ, который браузер может отправить в диалог печати. Он будет иметь собственную структуру и стили; точное внешнее оформление исходных страниц PDF в этом представлении не сохраняется.

Такой результат полезен для рабочего конспекта. Читатель получает последовательность страниц с обнаруженным текстом и комментариями, а элементы управления просмотрщиком не занимают место на бумаге. Одновременно важно обозначить границу: список текста и заметок не является изменённым подписанным PDF или копией всех иллюстраций оригинала.

Выберем содержимое печатного документа

В предыдущих уроках уже появился ограниченный индекс текста по страницам. Используем его как исходные данные печатного представления. К каждой записи индекса присоединим заметки с тем же номером страницы. Таким образом, номер связывает два набора, но они остаются разными сущностями. Исходный текст взят из PDF, комментарий принадлежит читателю.

Для каждой страницы создаём отдельную секцию. В её заголовке явно пишем «извлечённый текст». Если строк нет, вставляем пояснение об отсутствии текстового слоя. Это лучше пустого места, которое можно ошибочно принять за отсутствие содержания. Сканы и сложные графические страницы могут быть важными, даже когда выбранный способ извлечения не даёт слов.

Полный модуль print.js строит элементы DOM:

export function fillPrintSheet(index, notes, target) {
  target.replaceChildren();
  for (const entry of index) {
    const section = document.createElement('section');
    const heading = document.createElement('h2');
    heading.textContent = `Страница ${entry.page}: извлечённый текст`;
    const text = document.createElement('p');
    text.textContent = entry.text || 'На странице не найден текстовый слой';
    section.append(heading, text);
    for (const note of notes.filter(item => item.page === entry.page)) {
      const paragraph = document.createElement('p');
      paragraph.textContent = `Заметка: ${note.text}`;
      section.append(paragraph);
    }
    target.append(section);
  }
}

Перед заполнением очищаем старое печатное представление. Иначе второй вызов накопил бы повторные секции и заметки. Данные добавляются через textContent; даже если собственная заметка содержит HTML-подобную строку, она печатается как текст. Мы не создаём отдельное окно с собранной строкой HTML и не подставляем в него содержимое документа без проверки.

В этом шаге заметки не имеют координат прямоугольника или ссылок на конкретный символ. Поэтому они идут после текста своей страницы. Для заметок на полях понадобится другой договор привязки и более сложная вёрстка. Номер страницы достаточен для первого конспекта, но не позволяет вычислить, рядом с каким абзацем следует поставить комментарий. Не нужно выводить такую связь из положения заметки в массиве.

Печать использует другую таблицу стилей

Экранный интерфейс предназначен для управления просмотрщиком. На печати нужны последовательное чтение, поля листа и переносы. Добавим правила @media print, которые скрывают кнопки, canvas, поиск и формы, оставляя только print-sheet. Заголовок самого приложения также скрываем: печатное представление содержит свои смысловые заголовки.

@media print {
  body {
    margin: 0;
    max-width: none;
    color: black;
    background: white;
    font: 12pt/1.45 serif;
  }
  main > :not(#print-sheet), header { display: none !important; }
  #print-sheet { display: block; }
  #print-sheet section { break-before: page; }
  #print-sheet section:first-child { break-before: auto; }
  #print-sheet p { white-space: pre-wrap; overflow-wrap: anywhere; }
}
@page { size: A4; margin: 18mm; }

На экране print-sheet скрыт обычным правилом. Во время печати он появляется, а остальные прямые дети main скрываются. Селектор относится к нашему небольшому каркасу. Если позднее обернуть форму и печатную секцию общим контейнером, старое правило нужно пересмотреть: CSS-селектор не знает замысел автора и действует по фактической структуре DOM.

Перед каждой секцией просим разрыв страницы. Первая секция получает исключение, чтобы не создавать пустой лист в начале. Это не означает, что одному номеру PDF всегда соответствует ровно один бумажный лист. Длинный извлечённый текст и заметки могут занять несколько листов. Мы сохранили логическое начало страницы, но позволили браузеру переносить длинное содержимое внутри секции.

@page задаёт предпочитаемый размер A4 и поля. Пользовательский диалог, драйвер принтера и настройки браузера могут повлиять на окончательный результат. Нельзя обещать одинаковую пагинацию на всех устройствах без отдельного просмотра. Документация печатных стилей помогает разделить экранные правила и правила печати, но не заменяет оценку конкретного документа в выбранной среде.

Подготовим данные до открытия диалога

Кнопка получает действующий PDF и проверяет наличие заметок. Затем ожидает индекс через общую функцию getIndex. Если индекс уже построен поиском, используем готовый массив; если нет, присоединяемся к его построению. Не запускаем второй независимый обход страниц только потому, что появился другой способ использовать текст.

const pdf = viewer.pdf;
if (!pdf || !notes) return;
const current = await getIndex(pdf);
if (!current || viewer.pdf !== pdf) return;
index = current;
fillPrintSheet(index, notes.list(), document.querySelector('#print-sheet'));
window.print();

Этот фрагмент расположен в обработчике кнопки; полный main.js также обрабатывает отказ. После ожидания снова проверяем документ. При смене файла старый индекс не должен подготовить печать для нового интерфейса. Снимок заметок получаем после актуального индекса, поэтому в конспект входят записи действующего набора на момент формирования DOM.

Вызов window.print открывает механизм печати браузера. Он не выдаёт приложению путь к готовому файлу и не доказывает, что пользователь действительно распечатал документ. Пользователь может отменить диалог или выбрать сохранение в PDF, если среда предлагает такой вариант. Поэтому статус «файл успешно сохранён» после простого вызова метода был бы неверным. Описание метода print относится к открытию печатного диалога и его поведению.

Почему не печатаем один canvas

Текущий canvas представляет одну показанную страницу. Если просто скрыть кнопки и вызвать печать, в результат попадёт только она, а заметки могут остаться в другом месте интерфейса. Рендер всех страниц на отдельных canvas решает другую задачу, но способен выделить много памяти и требует управления окончанием всех работ. В нашей лаборатории выбираем текстовый конспект, который использует уже ограниченный индекс.

Такой подход имеет собственную цену. Иллюстрации, геометрия таблиц и оформление исходных листов не переносятся в конспект. Для юридически значимого документа или точной копии отчёта нужно отдельно предложить исходный PDF и подходящий процесс печати. Конспект полезен именно как производное представление для чтения и собственных записей; он не заменяет оригинал во всех сценариях.

На собственном двухстраничном образце ожидаются две логические секции с английским текстом и заметками к соответствующим номерам. Кнопки поиска, изменения масштаба и импорта не должны попадать в печатную версию. Длинная заметка должна переноситься, а не обрезаться по ширине. Эти условия предназначены для самостоятельного просмотра печати; при подготовке урока диалог печати и генерация результата не запускались.

В последнем уроке завершим границы просмотрщика: ограничим входные байты, количество страниц и поверхность рендера, добавим открытие локального файла и корректное завершение работы. Это позволит обсуждать не только удобный результат, но и стоимость обработки документа, который ещё не был известен приложению заранее.

Полные файлы этого шага находятся в папке lesson-09 архива «Лист». Учебный просмотрщик, библиотека и генератор PDF при подготовке не запускались; результаты выше ожидаются из разбора кода.