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

Почему значение поля PDF откатывается: direct-поля в Delphi

Значение поля формы PDF «откатывается», когда поле хранится как прямой словарь: PDFium повышает копию в массиве /Annots страницы до непрямого объекта, а /Fields держит вторую прямую копию. PDFium Component for Delphi обрабатывает типизированные записи полей, унифицируя обе ссылки в одного непрямого владельца перед записью

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

Симптом: успешная запись, которая не прилипает

Провальный паттерн узкий, но очень повторяемый: он проявляется только на формах, чьи словари полей записаны inline (прямыми объектами), а не нумерованными непрямыми объектами. Большинство авторских инструментов эмитят непрямые поля, поэтому проблема прячется месяцами, а потом всплывает на файлах одного конкретного генератора — часто самодельного экспортёра, пишущего весь /AcroForm inline

Два наблюдения вместе указывают на причину: запись сообщает успех, а нативное чтение поля через form-слой возвращает прежнее значение. Увидели и то и другое — прекратите подозревать свою кодировку строк и начните смотреть на владение объектами

Почему значение поля формы PDF откатывается после успешной записи?

Значение поля формы PDF откатывается, потому что два отдельных словаря оба претендуют быть тем же полем, а запись обновила копию, которую дерево полей не опрашивает. Значение поля живёт в его записи /V, и ISO 32000-1 §12.7.3.1 определяет, где эта запись авторитетна: на словаре поля, достижимом из /AcroForm /Fields, спускаясь по /Kids к терминальному полю, с наследуемыми записями, разрешаемыми от /Parent

Тот же пункт позволяет удобство, вызывающее все неприятности. Когда у поля ровно одна виджет-аннотация, словарь поля и словарь виджет-аннотации могут быть слиты в один словарь. В well-formed файле тот слитый словарь — один непрямой объект, на который ссылаются из двух мест: из /Fields (или /Kids родителя) и из массива /Annots страницы. Две ссылки, один объект, один /V

Две копии одного словаря

ISO 32000-1 §7.3.10 явен: идентичность, которой можно делиться, есть только у непрямого объекта. У прямого словаря нет номера объекта, поэтому, когда генератор пишет слитый словарь поля/виджета inline и в /Fields, и в /Annots, файл содержит не один объект, на который ссылаются дважды. Он содержит два одинаковых на вид словаря, случайно имеющих те же ключи и значения

PDFium это терпит и тем самым делает раскол постоянным. Когда ему нужен хендл на аннотацию, PDFium может повысить прямой словарь виджета в массиве /Annots страницы до непрямого объекта, но эквивалентная прямая копия остаётся где была, внутри /AcroForm /Fields (или внутри /Kids родителя). С этого момента документ держит двух владельцев одного логического поля. Писатель, правящий /V на аннотации страницы, получает подлинный успех на уровне словаря. Дерево полей — то, что form-слой и каждый последующий читатель трактуют как источник истины, — изменения никогда не увидит

Вот почему устаревшее значение переживает сохранение и переоткрытие: новое значение было честно сериализовано в объект, которым иерархия полей не владеет

PDFium Component на Delphi: два прямых словаря претендуют на одно поле, запись попадает в повышенную аннотацию, а дерево полей всё ещё читает старую копию из /Fields
запись была честно сериализована в объект, которым иерархия полей не владеет, поэтому устаревшее значение переживает сохранение и переоткрытие

Как PDFium Component восстанавливает единственного владельца поля

PDFium Component нормализует прямое владение полями перед каждой типизированной записью: он повышает прямые /AcroForm и словари полей до непрямых объектов, связывает каждый прямой виджет с его парной аннотацией страницы и переписывает /Fields, /Kids, /Parent и /Annots так, что каждая ссылка указывает на один и тот же непрямой объект. И лишь затем он меняет /V, поэтому значение поля, выбранный виджет и сохранённый документ делят одного владельца

Трудное — идентичность. Два прямых словаря с идентичным содержимым не доказывают, что это один и тот же виджет: форма может законно содержать два похожих виджета. Нормализатор поэтому отказывается угадывать и требует явного, уникального имени аннотации /NM (ISO 32000-1 §12.5.2) на каждом прямом виджете. Правила, применяемые путём записи, строги:

  • Прямому виджету нужен непустой /NM длиной до 1024 единиц UTF-16, и ровно одна аннотация страницы может нести то же имя
  • Прямая копия и аннотация страницы обязаны сериализоваться одинаково, кроме /Parent и регенерируемого PDFium /AP; любое другое различие отвергает запись
  • Обе родительские связи проверяются отдельно, поэтому верный /Parent на аннотации страницы не может замаскировать конфликтующий на исходной прямой копии
  • Отсутствующий /Parent подставляется, только когда уникальное владение в дереве полей делает ответ однозначным, а эквивалентные прямые родительские копии перепривязываются к своему повышенному владельцу
  • Дубликаты полных имён полей, циклические или мультивладельческие деревья полей и поля подписи отвергаются до публикации чего-либо
  • Неродственные аннотации — например, ссылки на той же странице — и неродственные записи /AcroForm сохраняются нетронутыми

Нормализатор также работает под жёсткими бюджетами: 4096 посещений дерева полей, страниц или членов массива, глубина вложения 64, 256 новых непрямых объектов, 1 МиБ на повышаемый словарь, 4 МиБ повышенных байтов суммарно, 128 МиБ осмотренных словарных байтов и 4096 единиц UTF-16 на полное имя поля при 262144 на форму. Враждебный или битый файл упирается в бюджет и падает с сообщением об ошибке, а не превращает заполнение формы в отказ в обслуживании

Вся операция транзакционна. TPdf.SetFormFieldValues снимает снимок, строит инкрементное обновление с нормализованными объектами и новыми значениями, грузит кандидата и заменяет живой документ только после верификации кандидата. Если нормализация отвергает файл или более поздний запрос батча падает, исходный документ, его значения и его логический курсор полей остаются ровно как были

Как PDFium Component для Delphi унифицирует прямое владение полями AcroForm: снимок, проверка идентичности NM, унификация ссылок, запись значения и верифицированная публикация с явными путями отказа
прямые словари полей повышаются и перепривязываются к одному непрямому владельцу до изменения /V, а двусмысленное владение отвергается чисто

Запись и верификация значения прямого поля на Delphi

Безопасный паттерн — записать, затем прочитать назад через нативный form-слой и трактировать расхождение как провал, даже когда запись вернула True. TPdf.SetFormFieldValue адресует поле по индексу на текущей странице, принимает типизированный TPdfFormValue и заполняет TPdfFormValueWriteReport; TPdf.FormField[Index] читает значение назад через form-слой PDFium, который разрешает дерево полей, а не верит виджету

Паттерн записи и проверки для полей форм PDF на Delphi с PDFium Component: типизированный SetFormFieldValue, чтение назад через FormField, затем проверки сохранения и переоткрытия
один True доказывает лишь, что словарь изменился — читайте назад через дерево полей и проверяйте сохранение и переоткрытие
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue целится в текущую страницу
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // Проверяем против дерева полей, а не против собственной переменной
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

На файле с неразрешимым владением — например, прямой виджет без /NM — SetFormFieldValue возвращает False, Report.UpdatedWidgets равен нулю, а Report.ErrorMessage называет проблему, например "Direct widgets require an explicit unique NM identity". Это поведение, которое вы хотите: ясный отказ вместо молчаливой полузаписи. Ещё одно преусловие стоит знать. Типизированная запись меняет только аннотации и не захватывает нативный фокус, поэтому если редактор нативного поля всё ещё сфокусирован, вся транзакция отвергается до снятия снимка; вызывайте Pdf.ClearFormFieldFocus сознательно первым, а не повторяйте в цикле

Для нескольких полей, особенно между страницами, берите батч-форму. TPdf.SetFormFieldValues принимает от 1 до 256 запросов, применяет все к одному кандидату и публикует всё или ничего. TPdfFormValuesWriteReport.FailedRequest говорит, какой запрос сломал батч

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2; Batch.UpdatedWidgets считает каждый синхронизированный виджет
end;

Два запроса, адресующие одно терминальное поле, отвергаются как дублирующий владелец вместо того, чтобы дать победить последней записи, и исходный документ сохраняется. Значения чекбоксов и радио следуют тем же правилам; разделение /V против /AS для кнопок раскрыто в статье о значении поля чекбокса против состояния виджета в PDFium

Доказательство, что значение переживает сохранение и переоткрытие

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

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

Ещё две байтовые ловушки: Trim и трейлерный /ID

Нормализация прямых полей означает перечитывание сырых словарных байтов, а это поднимает две ловушки поменьше, релевантные любому Delphi-коду, трогающему синтаксис PDF

Первая — SysUtils.Trim на сырых PDF-байтах в AnsiString. Строковый литерал, скажем опция choice, может законно содержать текст UTF-16BE с BOM из байтов FE FF и встроенными NUL. В Unicode Delphi SysUtils.Trim принимает string, поэтому вызов молча конвертирует AnsiString в UTF-16 по активной ANSI-кодовой странице и конвертирует результат назад. В зависимости от той кодовой страницы круг может переписать старшие байты, повредить BOM и даже потревожить следующий разделитель. Безопасный подход держит лексер на байтах: пропускайте PDF-пробелы по позиции (ISO 32000-1 §7.2.2 перечисляет NUL, tab, LF, FF, CR и space) и режьте через Copy, декодируя только когда известно, что значение — PDF-текстовая строка

// Рискованно на сырых PDF-байтах: AnsiString -> string -> AnsiString (W1057/W1058)
Token := Trim(Token);

// Безопасно по байтам: пропустить PDF-пробелы по позиции, затем отрезать
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

Вторая ловушка сидит в самом инкрементном обновлении. ISO 32000-1 §7.5.5 (Table 15) и §14.4 требуют, чтобы трейлерный /ID был массивом из двух байтовых строк. Внутренний хелпер, собирающий идентификатор разреженного обновления, возвращает только содержимое двух строк — без ключа /ID и скобок массива, — и трейлер обязан обернуть это явно как /ID [ ... ]. Снисходительный загрузчик PDFium и последующее полное пересохранение оба прячут отсутствующую обёртку, поэтому надёжная проверка — парсить кандидата и сохранённый файл независимым строгим парсером и искать две 16-байтовые строки. Пишете инкрементные обновления сами — та же ловушка в силе; статья об инкрементных обновлениях для зашифрованных PDF глубже разбирает цепочку трейлера

Что делать в собственном Delphi-коде форм?

  • Всегда читайте значение назад через FormField[Index] после записи, а в тестах ещё и сохраняйте, переоткрывайте и читайте снова
  • Трактуйте False плюс ErrorMessage как информацию: отказ, называющий NM, конфликт родителя или дубликат имени, означает, что владение в файле по-настоящему двусмысленно
  • Если вы генерируете PDF, эмитьте поля и виджеты непрямыми объектами и давайте каждому виджету уникальный /NM; это снимает двусмысленность у источника
  • Используйте SetFormFieldValues для много-полевых или межстраничных заполнений, чтобы поздний провал не мог оставить половину формы обновлённой
  • Никогда не гоняйте Trim и прочие string-хелперы RTL по сырым PDF-байтам в AnsiString

Будьте ясны и о границах. Подписанные документы, XFA-формы, зашифрованные снимки и файлы, чьё владение виджетами не разрешается однозначно, остаются вне заявленного профиля и отвергаются, а не продавливаются силой. Если ваш конвейер переносит поля между документами, вопросы владения становятся ещё труднее; пересадка AcroForm-полей между PDF-документами разбирает тот случай

PDFium Component приносит эту типизированную, транзакционную запись форм в приложения Delphi, C++Builder и Lazarus — рядом с рендерингом, извлечением текста и редактированием документов. История версий, лицензирование и загрузки — на странице продукта PDFium Component for Delphi