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

PDF.js: страницы и последовательная отрисовка

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

«Лист» умеет показывать первую страницу. Добавим переходы к соседним страницам и разберём ситуацию, которая часто появляется раньше, чем приложение становится большим: пользователь нажимает кнопку быстрее, чем заканчивается отрисовка. Новый результат — просмотр нужной страницы через общую последовательную очередь, без одновременного использования одного canvas двумя задачами.

В этом шаге выделим механизм просмотра в viewer.js, а обработчики кнопок оставим в main.js. Полные версии обоих файлов находятся в архиве третьего урока. Приведённые ниже методы являются частями класса Viewer; они используют его поля pdf, page, chain, ticket, activeRender и элементы интерфейса, которые создаются в конструкторе.

Запрошенная страница и показанная страница

У состояния интерфейса есть два разных смысла. page — последняя страница, которую запросил пользователь. shownPage — страница, завершившая отрисовку. Если человек быстро нажал «Вперёд» два раза, последней целью может быть третья страница, хотя поверхность ещё показывает первую или уже очищена. Одно поле для обоих смыслов скрывает этот промежуток и приводит к ошибкам в дополнительных действиях.

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

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

Одна поверхность требует порядка

В исходнике PDF.js закреплённой версии отрисовка одного canvas несколькими задачами одновременно запрещена. Это понятное ограничение: каждая задача использует состояние одной поверхности. Простое создание нового Promise не устраняет совместное использование canvas. Нужен порядок, в котором предыдущая работа завершена либо отменена до начала следующей.

Для этого chain хранит цепочку задач. Новый запрос добавляет обработчик к концу цепочки, а счётчик ticket показывает, относится ли этот обработчик к последней цели. Отмена увеличивает счётчик и прерывает активную задачу отрисовки. Её отказ всё равно должен быть обработан: отмена не превращает Promise в успешно выполненный рендер.

Метод отмены из класса Viewer выглядит так:

cancel() {
  this.ticket++;
  this.activeRender?.cancel();
  this.activeText?.cancel();
}

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

При добавлении запроса запоминаем счётчик и объект документа. Если к моменту выполнения появился более новый запрос, устаревший обработчик возвращается до обращения к странице. Проверку повторяем после асинхронного getPage, поскольку за время ожидания цель тоже могла измениться. Отмена одной графической задачи не отменяет автоматически все уже начавшиеся асинхронные операции приложения.

this.chain = this.chain.then(async () => {
  if (ticket !== this.ticket) return;
  const page = await pdf.getPage(target.page);
  if (ticket !== this.ticket) return;
  const viewport = page.getViewport({ scale: target.scale, rotation: target.rotation });
  // Далее в полном методе задаются размеры поверхности.
  this.activeRender = page.render({ canvas: this.canvas, viewport });
  await this.activeRender.promise;
  this.activeRender = null;
  if (ticket !== this.ticket) return;
  this.shownPage = target.page;
  this.status.textContent = `Страница ${target.page} из ${pdf.numPages}`;
  this.onRendered?.(target.page);
}).catch(error => {
  this.activeRender = null;
  this.activeText = null;
  if (ticket === this.ticket) {
    this.status.textContent = `Отрисовка не завершена: ${error.name}`;
  }
});

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

Обновим кнопки после готового изображения

Класс принимает обработчик onRendered. Он вызывается только для последней цели, которая завершила работу. В main.js этот обработчик отключает «Назад» на первой странице и «Вперёд» на последней. Само содержимое документа остаётся внутри Viewer, а внешний код получает небольшой результат — показанный номер.

import { Viewer } from './viewer.js';
const status = document.querySelector('#status');
const previous = document.querySelector('#previous');
const next = document.querySelector('#next');
const viewer = new Viewer(document.querySelector('#page'), document.querySelector('#frame'), status, page => {
  previous.disabled = page <= 1;
  next.disabled = page >= viewer.pdf.numPages;
});
previous.addEventListener('click', () => viewer.request({ page: viewer.page - 1 }));
next.addEventListener('click', () => viewer.request({ page: viewer.page + 1 }));
try {
  await viewer.open();
} catch (error) { status.textContent = `Документ не открыт: ${error.name}`; }

Обратите внимание на первоначальное состояние кнопок в HTML: обе отключены до загрузки документа. Пока не известно число страниц, нельзя обещать корректный переход к следующей. После завершения первого рендера обработчик установит действительные состояния. Если загрузка завершится отказом, кнопки останутся отключёнными, а статус объяснит, что документ не открыт.

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

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

Как разобрать поведение самостоятельно

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

Отдельно рассмотрите ошибку страницы. Если графическая задача завершилась отказом, последняя подтверждённая цель не становится показанной. shownPage остаётся нулём, статус содержит объяснение, а цепочка допускает новый запрос. Будущие операции над заметками не смогут опереться на неподтверждённый номер. Так небольшое различие полей состояния помогает удерживать согласованность всего интерфейса.

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

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