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

PDF.js: поиск по страницам документа

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

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

В «Листе» индекс является массивом пар «номер страницы — текст». Для нашего образца он содержит две записи. Поиск не запрашивает внешний сервис и не отправляет содержимое документа на сервер. Однако локальная обработка тоже требует ограничений: небольшой бинарный файл способен содержать значительно больше извлечённого текста, чем ожидает автор интерфейса.

Индекс сохраняет границы страниц

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

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

export async function buildIndex(pdf, isCurrent = () => true) {
  if (pdf.numPages > 50) throw new Error('Поиск рассчитан максимум на 50 страниц');
  const index = [];
  let total = 0;
  for (let number = 1; number <= pdf.numPages; number++) {
    if (!isCurrent()) return null;
    const page = await pdf.getPage(number);
    const content = await page.getTextContent();
    if (!isCurrent()) return null;
    const text = content.items.filter(item => typeof item.str === 'string')
      .map(item => item.str + (item.hasEOL ? '\n' : ' ')).join('');
    total += text.length;
    if (total > 500000) throw new Error('Превышен лимит текста для поиска');
    index.push({ page: number, text });
  }
  return index;
}

export function findPages(index, query) {
  const needle = query.normalize('NFKC').toLocaleLowerCase('ru').trim();
  if (!needle || needle.length > 120) return [];
  return index.filter(item => item.text.normalize('NFKC')
    .toLocaleLowerCase('ru').includes(needle)).slice(0, 100);
}

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

Параметр isCurrent связывает индекс с открытым объектом PDF. В последнем уроке появится возможность сменить документ. Если во время извлечения страниц выбран другой файл, старый индекс больше не нужен новому интерфейсу. Проверяем условие перед обращением к странице и после получения текста. Сам вызов getTextContent не превращается от этого в мгновенно отменяемую операцию, но устаревший результат не продолжит цикл и не будет установлен как действующий индекс.

Нормализация имеет ограниченную цель

Запрос и текст проходят через normalize('NFKC') и приведение к нижнему регистру. Это уменьшает часть различий между совместимыми формами символов и регистром. Затем используем обычное вхождение подстроки. Для учебного поиска это ясный договор: возвращаются страницы с совпадающей последовательностью символов после выбранного преобразования.

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

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

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

Последний запрос должен получить свою выдачу

Построение индекса происходит при первом поиске. Повторные запросы используют уже подготовленный массив. Если человек отправит две формы подряд до готовности массива, не нужно запускать два одинаковых обхода документа. В main.js хранится общий indexPromise, к которому присоединяются ожидающие обработчики. После завершения переменная очищается, а действительный результат сохраняется в index.

У пользовательских запросов есть отдельный счётчик searchEpoch. Он отличается от счётчика отрисовки: один управляет списком результатов, другой — canvas. Перед ожиданием индекса сохраняем строку запроса и его номер. После ожидания проверяем, что документ прежний и запрос по-прежнему последний. Иначе медленный результат первой формы мог бы заменить список, который пользователь уже запросил второй формой.

const epoch = ++searchEpoch;
const query = new FormData(event.target).get('query');
const current = await getIndex(pdf);
if (!current || viewer.pdf !== pdf || epoch !== searchEpoch) return;
const found = findPages(current, query);

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

Вывод остаётся обычным текстом

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

Нажатие передаёт номер в viewer.request. Так поиск использует уже известные проверки номера, отмену и последовательность рендера. Отдельный прямой вызов page.render из обработчика результатов нарушил бы единый порядок использования canvas. Новая возможность должна присоединяться к существующему договору отображения, а не обходить его только потому, что пришла из другой формы.

Для образца запрос note должен найти вторую страницу, а canvas — первую. Несуществующий фрагмент должен дать сообщение об отсутствии совпадений. На скане без текстового слоя результат останется пустым даже при видимом слове. Эти случаи описывают ожидаемую работу извлечённого текста через API PDF.js. Следующий урок добавит собственные заметки читателя, которые будут храниться отдельно от поискового индекса исходного документа.

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