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

Первый PDF на canvas

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

В предыдущем уроке подготовили модель просмотрщика и генератор двухстраничного документа. Теперь соединим HTML с PDF.js и покажем первую страницу. Результат этого шага — изображение страницы на canvas и сообщение о числе страниц. Навигацию пока не добавляем: сначала важно разобраться, какие асинхронные операции происходят между URL файла и готовым изображением.

В «Листе» используем закреплённую версию pdfjs-dist 6.4.299. Сведения опубликованного пакета подтверждают эту версию и требования Node.js для подготовки файлов. Браузерная библиотека и её worker должны происходить из одного пакета. Произвольная замена только одного из этих файлов может привести к несовместимости сообщений между частями библиотеки.

Библиотека и worker работают вместе

Библиотечный модуль предоставляет функции интерфейсу. Worker выполняет часть работы с документом в отдельном контексте браузера. Это помогает отделить разбор PDF от основного потока, в котором обрабатываются события страницы. Однако наличие worker не обещает, что любая огромная страница будет рисоваться мгновенно: подготовка и вывод графики тоже требуют ресурсов.

В полном package.json явно задана одна версия. Пакеты при подготовке курса не устанавливались; следующие действия предназначены для самостоятельной работы с архивом. Сначала читатель создаёт образец командой python3 make_sample.py, затем устанавливает пакет и копирует его браузерные ресурсы. В качестве среды для этой подготовки выбираем Node.js 24, соответствующий требованию пакета.

{
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "dependencies": {
    "pdfjs-dist": "6.4.299"
  }
}

После установки файл vendor.mjs копирует библиотеку в локальную папку vendor. Это позволяет странице загружать библиотечные модули и документ с одного учебного адреса. Установка не выполняется браузером посетителя. В опубликованном приложении нужные ресурсы должны уже находиться в артефакте выпуска; папка node_modules не является публичным каталогом сайта.

import { cp, mkdir } from 'node:fs/promises';
await mkdir('vendor', { recursive: true });
for (const folder of ['build', 'web', 'cmaps', 'standard_fonts', 'wasm']) {
  await cp(`node_modules/pdfjs-dist/${folder}`, `vendor/${folder}`, { recursive: true });
}
await cp('node_modules/pdfjs-dist/LICENSE', 'vendor/LICENSE');

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

Для локального просмотра нужен HTTP-сервер. Например, в папке конкретного шага можно самостоятельно выполнить python3 -m http.server 4390 --bind 127.0.0.1 и открыть его в браузере. Привязка к loopback подходит лаборатории на одном компьютере. Адрес file:// не является поддерживаемым способом запуска нашего модульного приложения: загрузка модулей и worker связана с правилами происхождения ресурсов.

Загрузка не равна отрисовке

Ниже полный main.js второго шага. Импорт предоставляет getDocument и настройки worker. Сначала указываем путь к соответствующему модулю worker, затем создаём задачу загрузки документа. Возвращаемое значение является задачей, а не уже готовым документом. Её promise разрешается, когда доступен объект PDF для дальнейших операций.

import { getDocument, GlobalWorkerOptions } from './vendor/build/pdf.mjs';
GlobalWorkerOptions.workerSrc = './vendor/build/pdf.worker.mjs';
const status = document.querySelector('#status');
const canvas = document.querySelector('#page');
const loading = getDocument({
  url: './assets/study.pdf',
  standardFontDataUrl: './vendor/standard_fonts/',
  cMapUrl: './vendor/cmaps/', cMapPacked: true,
  wasmUrl: './vendor/wasm/'
});
try {
  const pdf = await loading.promise;
  const page = await pdf.getPage(1);
  const viewport = page.getViewport({ scale: 1 });
  canvas.width = Math.ceil(viewport.width);
  canvas.height = Math.ceil(viewport.height);
  await page.render({ canvas, viewport }).promise;
  status.textContent = `Страница 1 из ${pdf.numPages}`;
} catch (error) {
  status.textContent = `Документ не открыт: ${error.name}`;
}

После получения документа запрашиваем первую страницу через getPage(1). Это тоже асинхронная операция. Номер начинается с единицы, потому что речь идёт о внешнем номере страницы документа, а не об индексе массива JavaScript. Значение pdf.numPages относится ко всему документу; объект page описывает одну выбранную страницу. Позднее именно это различие позволит строить навигацию без хранения всех страниц одновременно.

getViewport преобразует геометрию страницы в размеры поверхности при заданном масштабе. В этом уроке масштаб равен единице, а специальные способы увеличения пока отсутствуют. Canvas получает рассчитанные размеры, затем page.render начинает отрисовку. У задачи отрисовки свой promise. Сообщение о готовой странице появляется после ожидания этого результата, а не сразу после вызова метода.

Если убрать последнее ожидание, пользователь может увидеть надпись «Страница 1 из 2», пока поверхность ещё не готова. В небольшом образце это иногда выглядит безобидно, но в более сложном документе рассогласование заметнее. Интерфейс должен сообщать о состоянии той операции, которую действительно завершил. Загрузка файла, получение структуры страницы и построение изображения — три связанные, но разные границы готовности.

Что означает масштаб единица

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

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

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

Разберём отказ загрузки

Блок catch показывает имя ошибки обычным текстом. Если образец ещё не создан, сервер должен ответить отсутствием файла, и документ не откроется. Если worker находится по неверному пути, причина будет другой. Если PDF повреждён, ошибка возникнет при разборе его данных. Одинаковое сообщение «не работает» не помогает различить эти случаи, поэтому на учебном шаге сохраняем хотя бы имя ошибки.

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

Ожидаемый результат — первая страница с тремя строками и статус «Страница 1 из 2». Canvas показывает изображение, но скопировать текст из него пока нельзя. Это ожидаемое ограничение растрового представления, а не свидетельство повреждения исходного файла. Официальные примеры PDF.js также разделяют получение документа, страницы и завершение отрисовки. В следующем уроке сохраним эти границы и добавим последовательное переключение между страницами.

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