Ошибки, ресурсы и границы просмотрщика
Оглавление серии «Документы в вебе» · Урок 10 из 10.
«Лист» прошёл путь от первой страницы до поиска, заметок, экспорта и печати. Завершим учебный просмотрщик явными ограничениями и возможностью открыть локальный PDF. Новый результат — контролируемая смена документа: прежние данные не переходят к новому файлу, загрузка получает предел байтов, а отрисовка — предел поверхности. Это профиль небольшой лаборатории, который можно последовательно расширять.
Раньше приложение читало только собственный образец. Такой файл известен автору заранее. При открытии произвольного локального документа исходные условия меняются: количество страниц, геометрия, изображения и наличие пароля уже неизвестны. Полезно перечислить договор входа до загрузки, чтобы ограничения не выглядели случайными отказами после нескольких успешных действий.
Байты ограничиваются во время чтения
Локальный файл сначала проверяется по file.size. Сетевой ответ тоже может содержать Content-Length, но одного этого заголовка недостаточно. Сервер может не прислать его, использовать потоковую передачу или вернуть другое количество байтов. Поэтому читаем тело частями и суммируем действительный объём. При превышении двух MiB прекращаем чтение и возвращаем ошибку.
Полный модуль limits.js содержит оба способа получения данных:
export const MAX_BYTES = 2 * 1024 * 1024;
export async function readBoundedPDF(url, signal) {
const response = await fetch(url, { signal, credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const declared = Number(response.headers.get('content-length'));
if (declared > MAX_BYTES) throw new Error('PDF больше 2 MiB');
if (!response.body) throw new Error('Поток ответа недоступен');
const reader = response.body.getReader();
const chunks = [];
let count = 0;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
count += value.byteLength;
if (count > MAX_BYTES) {
await reader.cancel();
throw new Error('PDF больше 2 MiB');
}
chunks.push(value);
}
} finally { reader.releaseLock(); }
const bytes = new Uint8Array(count);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes;
}
export async function readLocalPDF(file) {
if (!file || file.size > MAX_BYTES) throw new Error('Локальный PDF больше 2 MiB');
return new Uint8Array(await file.arrayBuffer());
}
Промежуточные части остаются в памяти до сборки общего массива. Затем создаётся дополнительный массив нужной длины. Следовательно, лимит входных байтов не равен точному пределу всей используемой памяти. Добавляются промежуточные данные, разбор PDF, шрифты, индекс текста и canvas. Важно не превращать понятный предел файла в неверное обещание, что приложение никогда не использует больше двух MiB.
У сетевой загрузки есть AbortController и таймер. Они ограничивают ожидание и чтение сетевого ответа. После передачи байтов PDF.js разбор выполняется другим механизмом, поэтому отмена fetch уже не останавливает автоматически работу библиотечного worker. Для самостоятельного ограничения времени разбора понадобится отдельное решение, которое завершает задачу загрузки PDF. В этом примере не объявляем сетевой таймер универсальным ограничителем всей обработки.
Открывать произвольный введённый URL интерфейс не предлагает. Сеть используется для собственного assets/study.pdf; другой документ человек выбирает локально через файловое поле. Это не серверное скачивание указанного пользователем адреса и не загрузка файла на сайт. Такой договор удерживает небольшую лабораторию внутри одного браузера и не добавляет незаметно серверный прокси.
Размер листа ограничивается отдельно
После разбора документа проверяем, что страниц не больше пятидесяти. Это согласуется с уже существующим пределом поискового индекса. Однако малое количество страниц тоже не гарантирует небольшой canvas: PDF способен задавать необычно большую геометрию одного листа. Поэтому перед выделением поверхности проверяем её стороны и площадь с учётом DPR.
Следующий фрагмент расположен в Viewer.request перед присваиванием размеров canvas:
if (viewport.width * dpr > 6000 || viewport.height * dpr > 6000
|| viewport.width * viewport.height * dpr * dpr > 4_000_000) {
throw new Error('Поверхность страницы превышает лимит');
}
Большой файл и большая поверхность являются разными причинами отказа. Документ может пройти лимит байтов, но не пройти выбранный масштаб на устройстве с высокой плотностью. Пользовательский интерфейс в более развитом продукте мог бы предложить меньший масштаб. В лаборатории оставляем понятную ошибку; следующий запрос допускается существующей очередью и может изменить масштаб на допустимое значение.
Параметры загрузки PDF.js также ограничивают размер внутренних изображений и область их обработки. Эти настройки не заменяют проверку нашего canvas и не обещают исчерпывающий контроль памяти документа. Превышающее выбранный профиль изображение может не отображаться. Поэтому успешный рендер с такими ограничениями нельзя автоматически объявлять точной копией всех элементов исходной страницы без сравнения с оригиналом.
Документ имеет своё поколение состояния
Метод open увеличивает generation, отменяет прежнюю сетевую операцию и текущий рендер. Затем ждёт завершения цепочки и вызывает destroy у прежней задачи загрузки. Только после освобождения старой связи начинает устанавливать новый PDF. API закреплённой PDF.js позволяет различать отмену одной отрисовки и завершение работы с документом.
При чтении байтов, вычислении digest и получении PDF проверяем поколение. Если просмотрщик уже закрыт или действие устарело, старый результат не получает право установить новый статус и объект документа. Это продолжение механизма актуальности, который использовали для страниц, поиска и импорта. У каждого вида работы свой срок жизни, но общая идея остаётся прежней: ожидание не сохраняет действительность исходных условий навсегда.
Внешний обработчик на время открытия нового документа сбрасывает заметки, индекс поиска, список найденных страниц и печатную секцию. Кнопки перехода отключаются. Если новый документ не открылся, старые заметки не показываются рядом с пустой областью как будто относятся к новому файлу. При успешном открытии создаётся новое хранилище по вычисленному идентификатору; для прежних байтов могут восстановиться прежние локальные записи.
Пока действует одно открытие, повторное событие выбора файла пропускается. Это простая политика нашего обработчика. Пользователь должен дождаться его завершения и при необходимости выбрать файл снова. Более развитый интерфейс мог бы показывать очередь последнего файла, но это потребует дополнительного управления состоянием. Не нужно считать внутреннее поле generation доказательством того, что внешняя форма уже реализует такую очередь.
Ошибки не означают одинаковую проблему
Неверный формат, повреждённая структура, пароль и превышение нашего профиля имеют разные причины. Ошибку показываем обычным текстом. Пароль защищённого PDF в этой версии интерфейса не запрашивается: файл может завершиться отказом библиотеки. Мы не реализовали хранение паролей, поля форм, цифровые подписи, выполнение действий из PDF или полноценный редактор аннотаций. Изображение и выделяемые символы относятся к выбранной задаче чтения.
Если графическая отрисовка не завершилась, shownPage остаётся нулём. Сохранение заметки не получает подтверждённого номера. При этом объект документа может быть доступен для извлечения текста; успешный разбор и успешное изображение остаются разными состояниями. Интерфейс настоящего продукта должен объяснять эту разницу точнее, чем одна общая зелёная отметка «готово».
При окончательном уходе со страницы вызываем dispose: отменяем работу, ждём цепочку, завершаем задачу документа и освобождаем поверхность. Для события pagehide проверяем persisted, чтобы не разрушать интерфейс, который браузер сохраняет для возврата через bfcache. Очистка ресурсов не должна мешать предусмотренному браузером восстановлению страницы. Простое безусловное уничтожение при каждом pagehide может оставить пустой просмотрщик после нажатия «Назад».
В результате курс даёт ограниченный локальный просмотрщик с независимыми представлениями документа, собственными заметками и явным переносом данных. Исходники всех десяти шагов подготовлены, но приложения, worker, генератор образца и печать не запускались. Перед самостоятельным использованием стоит пройти ожидаемые сценарии на своём окружении и решить, какие ограничения соответствуют вашему продукту. Для следующего расширения уже видны самостоятельные темы: серверные права на документы, совместные комментарии, OCR и перенос заметок между редакциями.
Полные файлы этого шага находятся в папке lesson-10 архива «Лист». Учебный просмотрщик, библиотека и генератор PDF при подготовке не запускались; результаты выше ожидаются из разбора кода.