Значение поля формы 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 восстанавливает единственного владельца поля
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 снимает снимок, строит инкрементное обновление с нормализованными объектами и новыми значениями, грузит кандидата и заменяет живой документ только после верификации кандидата. Если нормализация отвергает файл или более поздний запрос батча падает, исходный документ, его значения и его логический курсор полей остаются ровно как были
Запись и верификация значения прямого поля на Delphi
Безопасный паттерн — записать, затем прочитать назад через нативный form-слой и трактировать расхождение как провал, даже когда запись вернула True. TPdf.SetFormFieldValue адресует поле по индексу на текущей странице, принимает типизированный TPdfFormValue и заполняет TPdfFormValueWriteReport; TPdf.FormField[Index] читает значение назад через form-слой PDFium, который разрешает дерево полей, а не верит виджету
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