Техническая статья

Команды Web Capture SpiderInfo каталога в PDF на Delphi

PDF Library for Delphi (PDFlibPas) записывает метаданные команд Web Capture в каталог документа через AddWebCaptureCommand, заполняя словарь /SpiderInfo, определённый в ISO 32000-1 §14.10. Каждая команда фиксирует URL, который был загружен, на сколько уровней вглубь ушла загрузка и небольшой набор флагов. Это запись намерения — сама библиотека ничего не загружает

Именно это различие — причина, по которой этот API так легко прочитать неправильно. Инженер поддержки приносит вам PDF, собранный из онлайн-документации вендора, и спрашивает, откуда взялась страница 40. Если производитель заполнил /SpiderInfo, ответ лежит в каталоге: URL, глубина обхода, возможно, тело POST-запроса, из которого родилась страница. Если нет — сколько ни читай поток содержимого, ничего не узнаешь. Запись этого происхождения дешёвая, структурно капризная и почти всегда пропускается

Что на самом деле записывает словарь /SpiderInfo каталога?

/SpiderInfo — это словарь информации Web Capture, висящий на каталоге документа, и в нём ровно две вещи, которые волнуют PDF Library for Delphi: /V — числовая версия базы данных Web Capture, которую библиотека всегда пишет как 1.0, и /C — массив словарей команд. Номер версии — это не версия PDF: он идентифицирует саму модель данных Web Capture, и 1.0 — единственное значение, определённое в ISO 32000-1 §14.10. Всё интересное живёт в /C. Каждый словарь команды несёт /URL (адрес запроса), /L (сколько уровней ссылок было пройдено от него) и /F (флаги обхода). Три необязательных ключа описывают сам запрос, когда это не был обычный GET: /P для данных POST, /CT для типа содержимого этих данных и /H для дополнительных заголовков запроса. PDF Library for Delphi отображает все шесть на одну запись, TPDFlibWebCaptureCommand, поля которой — URL, Levels, Flags, PostData, ContentType и Headers, все AnsiString, кроме двух счётчиков Integer. Наличие /SpiderInfo также поднимает минимальную версию документа: AddWebCaptureCommand вызывает EnsureMinVersion('1.3', 'Catalog /SpiderInfo Web Capture command'), а правила определения версии в PDF Library for Delphi регистрируют соответствующее правило PDF 1.3, так что документ, прочитанный позже, сообщает тот же минимум

Структура словаря SpiderInfo каталога, записываемого PDFlibPas: /SpiderInfo несёт /V, установленное в версию базы данных Web Capture 1.0, и /C как массив косвенных ссылок, где каждый словарь команды хранит /URL, /L, /F и необязательные ключи /P, /CT, /H, отображаемые на поля записи TPDFlibWebCaptureCommand
Список команд висит на каталоге документа как одна запись версии плюс массив косвенных ссылок, поэтому общий загруженный ресурс можно ссылать из нескольких мест, а минимальная версия поднимается до PDF 1.3

Почему команда Web Capture должна быть косвенным объектом

Единственное структурное правило, на котором все спотыкаются: словарь команды обязан быть зарегистрирован как косвенный объект до того, как попадёт в /C, потому что ISO 32000-1 §14.10 определяет /C как массив косвенных ссылок на словари команд, а не массив словарей. Запихните в этот массив прямой словарь — и получите то, что парсится как валидный PDF, но ничего не значит как запись Web Capture: вьюер или архивный инструмент, обходящий список команд, найдёт инлайн-объект там, где должна быть ссылка, и запись просто не будет командой. PDF Library for Delphi делает это внутри AddWebCaptureCommand: строит словарь, передаёт его OnNewIndObj, чтобы получить номер объекта и номер поколения, оборачивает эту пару в косвенную ссылку и добавляет ссылку в массив. Если OnNewIndObj не выдаёт положительный номер объекта, функция возвращает 0 и массив остаётся нетронутым. Спецификация настаивает на косвенности потому, что одна команда может разделяться — один и тот же загруженный ресурс можно ссылать из нескольких мест структуры Web Capture, — а разделяемые объекты в PDF по определению косвенные

Как добавить команду Web Capture из Delphi?

Заполните запись целиком и вызовите AddWebCaptureCommand, который возвращает новый индекс команды с единицы или 0, если валидация её отклонила. Полнота здесь важна больше обычного. Delphi инициализирует управляемые поля AnsiString локальной записи пустыми, а поля Integer — Levels и Flags — такой обработки не получают, так что запись, которую вы забыли заполнить, приходит с тем, что лежало на стеке. AddWebCaptureCommand требует, чтобы Levels был не меньше 1, а Flags — неотрицательным, и мусор со стека либо будет отклонён с порога, либо, что хуже, молча принят как правдоподобная глубина

Поток валидации AddWebCaptureCommand в PDFlibPas: заполняйте каждое поле записи, потому что Levels и Flags — обычные Integers с мусором со стека, требуйте Levels не меньше 1, отклоняйте Flags вне whitelist 1, 2, 4, создавайте /SpiderInfo лениво, регистрируйте команду как косвенный объект через OnNewIndObj и возвращайте индекс с единицы или 0
Каждое отклонение происходит до касания документа: запись, не прошедшая проверки счётчиков или флагов, возвращает 0, оставляет каталог ровно как был и никогда не пишет в файл пустую заглушку
var
  Lib: TPDFlib;
  Command: TPDFlibWebCaptureCommand;
  Index: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('captured-site.pdf', '') = 1 then
    begin
      // Заполните каждое поле: Levels и Flags — обычные Integers,
      // и если их пропустить, там окажется мусор со стека.
      Command.URL := 'https://example.com/docs/index.html';
      Command.Levels := 2;
      Command.Flags := PDF_WEB_CAPTURE_SAME_SITE or PDF_WEB_CAPTURE_SAME_PATH;
      Command.PostData := '';
      Command.ContentType := '';
      Command.Headers := '';

      // Возвращает новый индекс команды с единицы или 0 при отклонении.
      Index := Lib.AddWebCaptureCommand(Command);
      if Index = 0 then
        raise Exception.Create('Web Capture command rejected');

      Lib.SaveToFile('captured-site.pdf');
    end;
  finally
    Lib.Free;
  end;
end;

Первый успешный вызов и создаёт структуру. AddWebCaptureCommand просит у внутреннего хелпера WebCaptureCommands(True) массив команд, и этот хелпер создаёт /SpiderInfo с записью /V, а затем пустой массив /C, только если их нет. Он намеренно консервативен к тому, что находит: если в каталоге уже есть ключ /SpiderInfo, значение которого не словарь, или ключ /C, значение которого не массив, хелпер возвращает nil, а не перезаписывает существующий объект. Битая унаследованная структура приводит к провалу добавления; она не заставляет PDF Library for Delphi уничтожать то, что написал предыдущий производитель

Белый список флагов: три определённых бита и ничего больше

AddWebCaptureCommand валидирует Flags по whitelist и отклоняет любое значение с битом вне трёх констант, определённых в PDF Library for Delphi: PDF_WEB_CAPTURE_SAME_SITE = 1 (следовать только за ссылками, остающимися на том же сайте), PDF_WEB_CAPTURE_SAME_PATH = 2 (следовать только за ссылками в пределах того же пути) и PDF_WEB_CAPTURE_SUBMIT = 4 (этот запрос был отправкой формы, а не обычным получением). Передайте 8, или 16, или случайное -1 — и вызов вернёт 0, не коснувшись документа

Whitelist флагов, применяемый AddWebCaptureCommand в PDFlibPas: битовое поле /F принимает ровно три определённых бита, PDF_WEB_CAPTURE_SAME_SITE равный 1, PDF_WEB_CAPTURE_SAME_PATH равный 2 и PDF_WEB_CAPTURE_SUBMIT равный 4, тогда как любой неопределённый бит вроде 8 или случайная минус единица отклоняются при записи
Неопределённый бит неотличим от испорченного, поэтому отказ в момент записи не пускает двусмысленность в файл, а легальная комбинация вроде 1 или 2, дающая 3, по-прежнему проходит

Это строже, чем обычно бывают PDF-парсеры, и намеренно. /F — битовое поле без самописывающей структуры, так что неопределённый бит неотличим от испорченного, и потребитель, читающий значение позже, не сможет отличить будущее расширение от опечатки. Отказ при записи убирает двусмысленность из файла целиком. Заметьте также, что /P, /CT и /H пишутся только когда соответствующие поля записи непустые, так что обычный GET даёт словарь команды из трёх ключей — /URL, /L, /F — без загромождающих его пустых строк, и именно этого читатель файла ожидает для запроса без тела

// Отправка формы: тело POST плюс его тип содержимого.
Command.URL := 'https://example.com/search';
Command.Levels := 1;
Command.Flags := PDF_WEB_CAPTURE_SUBMIT;          // 4
Command.PostData := 'q=iso32000&scope=all';
Command.ContentType := 'application/x-www-form-urlencoded';
Command.Headers := 'Accept-Language: en-US';
if Lib.AddWebCaptureCommand(Command) = 0 then
  raise Exception.Create('submission command rejected');

// Same-site плюс same-path — легальная комбинация: 1 или 2 = 3.
Command.Flags := PDF_WEB_CAPTURE_SAME_SITE or PDF_WEB_CAPTURE_SAME_PATH;

// Бит 3 спецификацией не определён, так что этот вариант до файла не дойдёт.
Command.Flags := 8;
if Lib.AddWebCaptureCommand(Command) <> 0 then
  raise Exception.Create('undefined flag bit should have been refused');

Чтение команд обратно: один ключ и два допустимых представления

GetWebCaptureCommand возвращает 1 при успехе и 0 иначе, и он обязан быть терпимее писателя, потому что читает файлы, написанные другими производителями. Здесь важны две асимметрии. Первая: читатель принимает запись команды и как косвенную ссылку, и как прямой словарь, разыменовывая при необходимости — PDF Library for Delphi всегда пишет косвенную форму, требуемую спецификацией, но отказ читать неряшливый файл не помогает никому. Вторая: /P может быть и строкой, и потоком, и это обобщение выходит далеко за пределы Web Capture. ISO 32000-1 многократно разрешает одному ключу держать либо строку, либо поток, когда значение может быть сколь угодно большим — тела POST здесь, и тот же паттерн встречается в формате в метаданных и встроенных полезных нагрузках. Внутренний хелпер ReadString в GetWebCaptureCommand поэтому принимает флаг AllowStream, и только чтение /P передаёт True: значение-поток декодируется через свою цепочку фильтров и возвращается как поле PostData, тогда как /URL, /CT и /H остаются только строками, потому что спецификация не предлагает им альтернативу-поток. Если вы пишете свой код чтения PDF, эту привычку стоит позаимствовать — проверяйте по спецификации, какие ключи двутипизированы, вместо того чтобы предполагать строку

var
  Lib: TPDFlib;
  Command: TPDFlibWebCaptureCommand;
  I, Total, Removed: Integer;
begin
  Total := Lib.GetWebCaptureCommandCount;
  // Публичная индексация с единицы, как и в остальном PDF Library for Delphi.
  for I := 1 to Total do
    if Lib.GetWebCaptureCommand(I, Command) = 1 then
      Writeln(Format('%s  levels=%d  flags=%d  post=%d bytes',
        [Command.URL, Command.Levels, Command.Flags, Length(Command.PostData)]));

  // Удаляет весь словарь /SpiderInfo и сообщает, сколько команд
  // было удалено; возвращает 0, когда очищать нечего.
  Removed := Lib.ClearWebCaptureCommands;
  Writeln(Format('cleared %d command(s)', [Removed]));
end;

ClearWebCaptureCommands считает команды и затем вычищает весь ключ /SpiderInfo из каталога, а не просто опустошает /C. Оставить /SpiderInfo с пустым массивом команд означало бы утверждать, что документ имеет происхождение Web Capture и что ничего не было загружено, — а это другое и ложное заявление, нежели отсутствие записанного происхождения. Тот же инстинкт применим к метаданным каталога в целом: пустой словарь-оболочка — это утверждение, а не нейтральный остаток. Если вы аудируете, что документ уже заявляет, перед переписыванием, паттерн интроспекции из статьи про чтение структуры оглавления, аннотаций и действий из существующего PDF естественно сочетается здесь с проходом «прочитай — затем очисти»

Запись метаданных — это не реализация Web Capture

Будьте честны с собой насчёт границ: AddWebCaptureCommand сохраняет запись о намеренной или выполненной загрузке — и только. PDF Library for Delphi не выполняет HTTP-запросы, не следует за ссылками, которые описывает ваше значение Levels, и не соблюдает флаги SAME_SITE или SAME_PATH — эти флаги документируют, как вёл себя какой-то краулер, а не инструкции, которые выполняет библиотека. Ваш собственный код загрузки, какой бы он ни был, остаётся полностью вашей проблемой

Ещё два куска ISO 32000-1 §14.10 тоже вне этого API. Web Capture определяет наборы содержимого — структуры /Contents, которые привязывают отдельные части страниц к исходному материалу, из которого они пришли, — и дерево имён, отображающее цифровые идентификаторы на эти наборы. PDF Library for Delphi не ведёт ни того, ни другого. Что вы получаете — это список команд: верхнеуровневый ответ на вопрос «откуда был загружен этот документ и насколько глубоко ушёл обход», который для происхождения, аудита и архивного приёма обычно и есть та часть, о которой вообще спрашивают. Если URL нужны кликабельными для читателей, а не только записанными для аудиторов, это отдельная работа — см. статью про построение действий GoToR, GoToE и Launch для стороны действий и про триггеры действий жизненного цикла документа и страницы для поведения при открытии или закрытии. Метаданные команд /SpiderInfo каталога, описанные здесь, — часть PDF Library for Delphi, нативной PDF-библиотеки для Delphi и C++Builder