Заметки рядом с PDF
Оглавление серии «Документы в вебе» · Урок 7 из 10.
Поиск помогает вернуться к словам автора, но читателю нужны и собственные записи. Добавим заметки к показанной странице. Результат этого урока — локальное сохранение текста, привязанного к определённым байтам документа и номеру страницы. Изображение PDF остаётся прежним: заметки относятся к нашему приложению и не встраиваются в исходный файл.
Раньше библиотека загружала PDF по URL. Теперь сначала получим байты, вычислим идентификатор версии, а затем передадим данные в getDocument. Такое изменение нужно не ради отрисовки: оно задаёт устойчивую связь между документом и пользовательскими заметками. Одного имени study.pdf для этой связи недостаточно, поскольку содержимое файла по тому же адресу может измениться.
Адрес и версия обозначают разные вещи
URL помогает получить файл, но не обязательно однозначно определяет его содержимое во времени. Редактор может заменить методичку, сохранив адрес. Пятая страница нового выпуска способна содержать совсем другую тему. Если привязать старую заметку только к URL и номеру страницы, приложение без объяснения покажет её рядом с новым материалом. Это выглядит как полезное сохранение, но фактически разрушает смысл записи.
В лаборатории используем SHA-256 всех байтов PDF. Получившийся идентификатор меняется при изменении файла, в том числе при изменении его метаданных. Эта строгость полезна для первоначального договора: заметки относятся к точной бинарной версии. Но она не решает задачу переноса комментариев между близкими редакциями. Для такого переноса нужно сравнение версий и отдельное подтверждение читателя, а не автоматическое сопоставление одинаковых номеров страниц.
Участок Viewer.open вычисляет идентификатор до передачи массива библиотеке:
const response = await fetch('./assets/study.pdf');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const digest = await crypto.subtle.digest('SHA-256', bytes);
this.documentId = Array.from(new Uint8Array(digest),
b => b.toString(16).padStart(2, '0')).join('');
this.loading = getDocument({
data: bytes,
cMapUrl: './vendor/cmaps/', cMapPacked: true,
standardFontDataUrl: './vendor/standard_fonts/',
wasmUrl: './vendor/wasm/'
});
this.pdf = await this.loading.promise;
await this.request({ page: 1 });
crypto.subtle.digest является асинхронной операцией Web Crypto API. Ей нужна подходящая безопасная среда; локальный loopback-адрес используется для разработки. Сначала ждём результат digest, затем отдаём данные PDF.js. Библиотека может передавать типизированный массив своему worker с передачей владения, поэтому не следует планировать последующие операции над тем же буфером без понимания его состояния.
Хеш здесь не является подписью издателя, доказательством безопасности PDF или разрешением на доступ. Он только связывает наши записи с определёнными байтами. Если злоумышленник передаст опасный файл вместе с его корректным хешем, факт совпадения ничего не говорит о доверии к содержимому. Авторизация, происхождение файла и целостность конкретной версии — разные договоры.
Запись имеет собственный идентификатор
Заметка содержит id, page и text. Идентификатор заметки позволяет удалять одну запись, даже если несколько записей имеют одинаковый текст. Номер страницы проверяется по фактическому числу страниц документа. Текст ограничен двумя тысячами символов, а число записей — двумястами. Эти пределы нужны для понятного локального хранения, а не для имитации бесконечной серверной базы.
Полный модуль notes.js проверяет входные данные и предоставляет операции чтения, добавления, удаления и замены:
const MAX_NOTES = 200;
export function validateNotes(value, pageCount) {
if (!Array.isArray(value) || value.length > MAX_NOTES) throw new Error('Неверный список заметок');
const ids = new Set();
return value.map(item => {
if (!item || typeof item.id !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(item.id)
|| ids.has(item.id) || !Number.isInteger(item.page) || item.page < 1
|| item.page > pageCount || typeof item.text !== 'string'
|| !item.text.trim() || item.text.length > 2000) throw new Error('Неверная заметка');
ids.add(item.id);
return { id: item.id, page: item.page, text: item.text };
});
}
export function createNotes(documentId, pageCount) {
const key = `list-notes:v1:${documentId}`;
let values = [];
let storageError = '';
try { values = validateNotes(JSON.parse(localStorage.getItem(key) || '[]'), pageCount); }
catch { storageError = 'Локальные заметки не прочитаны'; }
function replace(next) {
const clean = validateNotes(next, pageCount);
localStorage.setItem(key, JSON.stringify(clean));
values = clean;
storageError = '';
}
return {
list: () => structuredClone(values),
error: () => storageError,
replace,
add(page, text) { replace([...values, { id: crypto.randomUUID(), page, text: text.trim() }]); },
remove(id) { replace(values.filter(item => item.id !== id)); }
};
}
Валидация создаёт новые объекты только с разрешёнными полями. Это важнее, чем просто проверить наличие трёх свойств и затем сохранить исходный объект целиком. Ненужные поля из будущего импорта не переходят в рабочее состояние. Повторяющиеся идентификаторы отклоняются, иначе удаление по одному id могло бы затронуть несколько заметок сразу.
Функция list возвращает копию массива. Внешний код не может незаметно изменить рабочие записи, получив ссылку на внутренний объект. Для изменения есть конкретные методы, в которых выполняются валидация и сохранение. Этот небольшой интерфейс помогает удерживать согласованность данных даже без сложного фреймворка состояния.
Сначала сохранение, затем изменение памяти
Метод replace сначала проверяет весь новый набор, затем записывает JSON в localStorage и только после успешной записи меняет массив в памяти. Если хранилище недоступно или квота исчерпана, пользователь получает сообщение, а прежнее рабочее состояние сохраняется. Нельзя показывать «сохранено» только потому, что новый объект добавлен в обычный массив JavaScript.
При первоначальном чтении ошибка имеет другой смысл. Некорректный JSON или недействительные записи не проходят валидацию, поэтому модуль начинает с пустого рабочего набора и сохраняет сообщение о проблеме чтения. Он не перезаписывает повреждённую строку автоматически во время инициализации. Первое явное успешное изменение уже записывает новый корректный набор. Для продукта с ценными комментариями полезно добавить отдельный экспорт исходной повреждённой записи перед восстановлением.
Ключ хранилища включает версию нашей схемы и идентификатор документа. Два одинаковых PDF на одном происхождении браузера будут обращаться к одной группе заметок. Файл с другими байтами получит другой ключ. Это удобная локальная модель, но она не создаёт аккаунт пользователя и не синхронизирует записи между устройствами. Очистка данных сайта способна удалить сохранённые заметки, поэтому в следующем уроке добавим явный экспорт.
Используем показанный номер страницы
Обработчик формы получает viewer.shownPage, а не последнюю запрошенную page. Пока новый рендер не завершён, показанный номер равен нулю, и заметка не добавляется. Так запись не прикрепится к странице, которую пользователь ещё не увидел. Это практическое применение различия состояний из третьего урока: оно влияет на собственные данные читателя.
В списке показываем заметки только к подтверждённой странице. Текст передаётся через textContent, а рядом создаётся кнопка удаления. При неуспешном сохранении поле формы не очищается, чтобы человек мог исправить проблему или скопировать свой текст. При успешной записи поле сбрасывается, список обновляется, и статус сообщает именно о локальном хранилище.
Модель допускает последнее сохранение одной вкладки поверх изменений другой. Мы не реализовали объединение конкурентных правок и обработку событий storage. Для локального учебного просмотрщика это явная граница. Если продукт предполагает совместные заметки, понадобится серверный договор версий или отдельный механизм синхронизации, а не незаметное увеличение лимита массива.
После самостоятельного запуска ожидается сохранение записи к первой странице, отдельная запись ко второй и восстановление обоих наборов после перезагрузки того же происхождения. В другом браузере заметки не появятся сами. При изменении байтов PDF прежние записи останутся под прежним ключом и не будут автоматически показаны новой версии. Далее подготовим переносимый JSON-архив, который сохраняет эту связь явно.
Полные файлы этого шага находятся в папке lesson-07 архива «Лист». Учебный просмотрщик, библиотека и генератор PDF при подготовке не запускались; результаты выше ожидаются из разбора кода.