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

Удаление аннотаций и OBJR в PDFium Component для Delphi

Удалите Link-аннотацию из тегированного PDF редактором, который трогает только страницу, — страница отрисуется как ни в чём не бывало, а дерево структуры всё ещё держит OBJR, запись ParentTree и элемент Link для аннотации, которой больше нет. RemovePdfPageAnnotations в PDFium Component отзывает аннотацию со страницы и, по запросу, убирает вместе с ней и эти элементы структуры

Как тегированная аннотация подключена к дереву структуры

Тегированная аннотация стоит на стыке четырёх объектов, и чистое удаление обязано навестить все четыре. ISO 32000-1 §14.7.4.3 говорит: когда содержимым элемента структуры является целый PDF-объект, например аннотация, массив /K элемента называет её словарём ссылки на объект (таблица 325): /Type /OBJR, обязательная косвенная ссылка /Obj на аннотацию и необязательная /Pg, перекрывающая собственную страницу элемента. Это направление «от дерева к странице»

Как тегированная Link-аннотация связана с массивом Annots страницы PDF, ссылкой OBJR, ключом StructParent и записью ParentTree в PDFium Component для Delphi
Тегированная аннотация подключена к четырём объектам, и чистое удаление навещает ссылку на странице, OBJR, ключ StructParent и пару ParentTree

Обратный путь — §14.7.4.4. Дерево родителей — это числовое дерево (§7.9.7) с записью для каждого объекта, являющегося элементом содержимого; ключом служит целое число, которое объект несёт. Аннотация несёт /StructParent, и её значение в дереве родителей — единственная косвенная ссылка на владеющий элемент; страница, чей поток содержимого содержит marked content, несёт вместо этого /StructParents, и её значение — массив, индексируемый MCID. Таблица 326 допускает в объекте не более одного из этих двух ключей, потому что объект — либо элемент содержимого целиком, либо контейнер для marked content. Для гиперссылок §14.8.4.4.2 позволяет элементу Link держать и видимые слова в виде ссылок на marked content (MCR), и OBJR на аннотацию ссылки. Удалите аннотацию — и вот что остаётся:

ГдеЧто там лежитЧто делает чистое удаление
Страница, /AnnotsКосвенная ссылка на аннотациюУбирает ссылку; уцелевшие сохраняют свой порядок
Элемент Link, /KOBJR с /Obj и /PgУдаляет элемент содержимого OBJR
Словарь аннотацииЦелочисленный ключ /StructParentУдаляет ключ: объект больше не элемент содержимого
Лист /Nums в ParentTreeКлюч в паре со ссылкой на элемент LinkУдаляет пару ключ—значение
Корень дерева структуры/ParentTreeNextKeyНе трогает

Как ссылка-призрак переживает свою аннотацию?

Редактор уровня страницы удаляет аннотацию, вынимая её из /Annots, и дальше один из двух путей. Если инструмент заодно освобождает объект аннотации, /Obj у OBJR теперь указывает на неопределённый объект, который §7.3.10 велит ридеру трактовать как null: элемент Link владеет элементом содержимого, который не разрешается ни во что. Если инструмент оставляет объект в файле, OBJR всё ещё разрешается, но его /Pg называет страницу, в чьём /Annots он больше не значится. В обоих случаях элемент Link сохраняет свои дочерние узлы marked content, поэтому слова «Скачать спецификацию» всё ещё помечены как ссылка, и экранный диктор, идущий по дереву в порядке чтения, может озвучить ссылку, которую на странице не активирует ничто. Проверяльщики PDF/UA вроде PAC и veraPDF, валидирующие по ISO 14289-1, сравнивают то, что перечисляет страница, с тем, что заявляет дерево, так что расхождение между ними может всплыть там, даже когда отрисованная страница выглядит безупречно

Два пути, оставляющие ссылку-призрак в тегированном PDF после удаления только на уровне страницы, и как PDFium Component вычищает их в Delphi
Освобождён объект аннотации или оставлен в файле, элемент Link сохраняет свои слова, и экранные дикторы могут озвучить ссылку, ведущую в никуда

Удаление содержимого имеет свою версию той же проблемы. Выньте текстовые объекты внутри /P <</MCID 4>> BDC … EMC и оставьте операторы — страница держит пустую последовательность marked content, чья MCR всё ещё сидит в элементе <P>; ридер получает абзац без слов. Удалите BDC, но не её EMC, и поток содержимого сломан наглухо. §14.6 требует, чтобы каждая последовательность marked content была обрамлена BDC или BMC и парной EMC внутри одного потока содержимого, а осиротевшая EMC оставляет каждую последующую последовательность на странице в паре не с тем открывающим оператором

Удаление аннотаций через RemovePdfPageAnnotations

RemovePdfPageAnnotations в FPdfDocumentStructure принимает исходный поток, индекс страницы с нуля в порядке дерева страниц, номера объектов удаляемых аннотаций, запись TPdfAnnotationRemovalOptions и два выходных параметра — новый файл и TPdfAnnotationRemovalReport. С TPdfAnnotationRemovalOptions.Default функция трогает только страницу: одна инкрементная ревизия несёт словарь страницы с укороченным массивом /Annots (или без ключа /Annots, когда уходит последняя аннотация) плюс нетронутый каталог, поэтому номера объектов, идентификатор документа и все прочие ключи страницы выживают. Объект аннотации сохраняет тело, и вызывающий код, всё ещё держащий его номер, может его прочитать. Дерево структуры остаётся как было — тегирование цело для случая, когда вы собираетесь направить тот элемент Link на замещающую аннотацию. Входные данные проверяются до того, как что-то будет записано:

  • Каждый номер обязан присутствовать в /Annots выбранной страницы и называть объект с /Type /Annot; номер, указанный дважды в одном вызове, отвергается
  • Виджеты отвергаются: виджет принадлежит своему полю формы, и снятие его со страницы оставило бы поле, чей виджет не отображается ни на одной странице
  • Шифрованный источник отклоняется: правка структуры требует расшифрованных байтов; расшифруйте файл заранее
  • MaxOutputBytes, MaxWork и CancellationToken ограничивают работу, а любой сбой возвращает False без выходных данных и с ErrorMessage, называющим этап, на котором всё остановилось

Для тегированного документа дерево структуры уже сообщает, какие номера аннотаций имеют значение. Каждый OBJR-элемент в снимке ReadPdfDocumentStructure несёт номер объекта, на который ссылается, а номер страницы берётся из собственного /Pg этого OBJR или, если его нет, из элемента. Подпрограмма ниже собирает все аннотации, на которые ссылается элемент Link на одной странице, и удаляет их с обеими включёнными опциями очистки. Держите фильтр по странице: номер, взятый с другой страницы, отвергнет всю партию

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfCompress, FPdfDocumentStructure;

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // Все аннотации, на которые указывает элемент Link на этой странице
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // OBJR-элементы и записи ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // элементы, оставшиеся пустыми внутри
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

Что происходит с ключом ParentTree

Со включённой RemoveOwnedStructurePeers каждый OBJR, чей /Obj — удалённая на этой странице аннотация, покидает свой элемент через редактор структуры, и обратный индекс остаётся в согласии. Пара ключ—значение, хранящаяся под /StructParent аннотации, вынимается из её листа /Nums, а сам ключ /StructParent удаляется из словаря аннотации, поскольку таблица 326 требует его только на объектах, являющихся структурными элементами содержимого. /ParentTreeNextKey не понижается. §14.7.4.4 требует лишь, чтобы он был больше каждого используемого ключа, чего удаление сломать не может, а ключи числового дерева не обязаны идти подряд, поэтому писатель, следующий за /ParentTreeNextKey, никогда не выдаст освобождённый ключ снова. Каждая уцелевшая аннотация сохраняет номер объекта, позицию в /Annots и ключ ParentTree; функция перечитывает записанную страницу и отвергает результат, если кто-то из уцелевших пропал или сменил место. Ожидаемо: на странице с одной тегированной ссылкой Report.RemovedAnnotations и Report.RemovedObjectReferences оба равны 1, а Report.SourceSha256 и Report.OutputSha256 фиксируют, какие байты вошли и какие вышли

Когда опустевший элемент тоже должен уйти?

RemoveEmptiedStructureNodes удаляет элементы, оставшиеся после удаления OBJR без элементов содержимого и без дочерних элементов, начиная с самых глубоких. Работает раундами: каждый раунд перечитывает структуру, удаляет опустевшие элементы и добавляет их родителей в кандидаты следующего раунда, так что цепочка обёрток схлопывается снизу вверх, а корень дерева структуры остаётся всегда. Элемент Annot, помечающий стикер, обычно не держит ничего, кроме OBJR (§14.8.4.4.3 это разрешает), поэтому он опустеет и уйдёт. Элемент Link другой. Он всё ещё владеет ссылками на marked content для своих видимых слов, поэтому не пуст и выживает — Link без аннотации. RemovePdfPageAnnotations не переименовывает его, потому что оставаться ли этим словам ссылкой — вопрос редакторский, а не структурный. Для чистого удаления включите обе опции, а затем понизьте любой оставшийся без OBJR элемент Link до Span: вспомогательные технологии прочитают эти слова как обычный текст вместо того, чтобы предлагать ссылку в никуда

Схема решения для опустевших элементов структуры: элемент Annot схлопывается раунд за раундом, элемент Link выживает, и PDFium Component может понизить его до Span
Опустевшие обёртки схлопываются начиная с самых глубоких, пока корень остаётся, а оставшийся без OBJR элемент Link можно понизить до Span или сохранить под замещающую аннотацию
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // Дайджест снимка страхует от изменений байтов между операциями
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Держите значения по умолчанию, когда удаление — половина замены. Опустевший элемент, оставленный на месте, всё ещё несёт свою роль, заголовок, альтернативный текст и идентификатор элемента, и если поздний шаг прикрепит к нему новую аннотацию операцией psekAddObjectReference, которая даёт аннотации свежий ключ /StructParent и сдвигает /ParentTreeNextKey по тем же правилам, по которым в дереве родителей регистрируются новые MCR и OBJR, элемент сохраняет своё место в порядке чтения. Удалить и пересоздать этот элемент — значит потерять эти значения и выдать ему новый номер объекта

Удаление тегированного содержимого: владение тянется от BDC до своей EMC

Аннотации — не объекты страницы, поэтому текст, пути и изображения идут через другой API: пакетный редактор объектов PlanStyledPdfObjectEdits и ExecuteStyledPdfObjectEdits в FPdfStyledEdit, чьи TPdfObjectEditOptions несут RemoveOwnedStructurePeers и RemoveEmptiedStructureNodes с тем же смыслом. Тегированная последовательность marked content уходит вместе со своей структурной записью только когда удаляется каждый объект между её BDC и парной EMC. В этом случае оба оператора покидают поток содержимого, MCR покидает свой элемент, а слот этого MCID в ParentTree-массиве страницы получает null, а не схлопывается, ведь все прочие MCID страницы индексируются в тот же массив. Последовательность, которая вдобавок обёртывает выживающее содержимое, остаётся вместе со своей MCR. Страница, переиспользующая удалённый MCID для выживающего содержимого, отвергается начисто: MCID привязаны к потоку содержимого страницы, а удаление структурной записи оставило бы выжившего сиротой. С опцией, оставленной в False, пакет вместо этого сохраняет пары BDC и EMC на границе удаления, так что дерево структуры продолжает разрешаться и в нём ничего не меняется

Владение надо мерить от BDC до конца его парной EMC. Наша первая версия мерила его по байтовому диапазону одного оператора BDC — именно там разобранная метка записывает свою позицию — и не нашла ни одного вложенного объекта. Родственная ошибка — пометить BDC на удаление и потерять её напарницу: это записывает ровно тот разбалансированный поток, что описан выше, поэтому редактор всегда удаляет обе половины пары вместе. Подпрограмма ниже удаляет каждый объект верхнего уровня, несущий один MCID. ExecuteStyledPdfObjectEdits перезагружает Pdf результатом, если не установлен EditOptions.PreviewOnly, а Report.RemovedStructurePeers и Report.RemovedStructureNodes считают, что ушло из дерева; функция возвращает тот отчёт

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber отсчитывается с единицы
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

Удалённая аннотация после любого из двух вызовов всё ещё занимает свой объект в файле, потому что отзывается только ссылка со страницы. Стоит OBJR уйти — и ничто в графе каталога больше до него не дотягивается, так что TPdf.SaveAsReachable может вымести этот объект-сироту из перезаписанной копии; с опциями по умолчанию OBJR держит аннотацию достижимой, и чистка оставляет её тоже. Запускайте удаление с обеими структурными опциями всякий раз, когда оно окончательное, — и страница, дерево структуры и дерево родителей в файле, который записывает назад PDFium Component для Delphi, описывают один и тот же набор аннотаций