HotPDF Component применяет поток FDF /Differences из Delphi через THPDFHeadlessDocument.SaveFDFDifferencesToFile: метод проверяет, что загруженный PDF всё ещё совпадает с базовой редакцией, которую вы задаёте точной длиной в байтах и SHA-256, добавляет накопительные байты редакции ровно один раз, прогоняет результат через проверки разрешений шифрования и допуска DocMDP/FieldMDP и публикует его атомарно. Повтор той же подачи — no-op с точностью до байта
Сценарий, ради которого это существует, непрестижный и ходовой. Вы отправляете договор или акт осмотра как PDF, кто-то заполняет его и подписывает в своём вьювере, и обратно приходит не PDF, а FDF-файл. Значения полей едут в /Fields, но сама подписанная редакция, байты, которые реально покрывает CMS-подпись, едет в /Differences. Ошибётесь со слиянием — либо сломаете подпись, либо, что хуже, примете значения полей, которые не покрывала ни одна подпись
Что поток FDF /Differences несёт на самом деле?
Поток FDF /Differences несёт сырые байты инкрементального обновления для целевого PDF, а не историю уровня FDF. ISO 32000-1 §12.7.7.3, Table 243 определяет запись Differences словаря FDF как инкрементальные изменения исходного PDF, сделанные с момента его открытия, в той же форме, что и PDF-инкрементальное обновление по §7.5.6: новые и заменённые объекты, секция перекрёстных ссылок и trailer, чей /Prev указывает обратно в базовый файл
Сам контейнер FDF — противоположность. §12.7.7.1 запрещает инкрементальные обновления FDF-файлов, поэтому у FDF единственный trailer, только объекты поколения 0 и уникальные номера объектов. HotPDF Component требует ровно этого в своём FDF-парсере и никогда не расслабляет это ради удобства /Differences. Инкрементальные данные живут внутри одного потока строго неинкрементального контейнера, и держать эти два слоя порознь — половина работы
Два свойства потока определяют каждое решение дальше по цепочке:
- Поток накопителен: он держит каждый байт, добавленный с момента открытия целевого PDF, поэтому вторая подача из той же сессии повторяет уже зафиксированную вами редакцию и добавляет новую за ней
- Внешние записи
/Fieldsи/Annotsтого же FDF не обязаны совпадать с/Differences, поэтому значения полей, которые легко прочитать, — не те данные, которые покрывает подпись
Второй пункт заслуживает прямоты. Если ваш импортирующий код читает /V из /Fields и считает результат подписанным потому, что в том же файле пришла подпись, вы построили канал подделки. HotPDF Component нарочно держит пути раздельно: FDF-импортёр аннотаций отвергает /Differences начисто, и только специальный транспорт Differences его трогает
Привязка добавки к явно указанной базовой редакции
SaveFDFDifferencesToFile отказывается угадывать, к какой редакции принадлежит FDF. Вызывающий передаёт BaseBytes — точную длину полной PDF-редакции, с которой была открыта форма, — и BaseSHA256, её 64-значный шестнадцатеричный SHA-256. Метод заново хеширует этот префикс загруженного документа, отвергает несовпадение с "The loaded PDF does not match the explicitly selected base" и проверяет, что префикс заканчивается на настоящем конце редакции, чей startxref указывает на его реальные данные перекрёстных ссылок
Практическое правило: записывайте оба числа, когда отправляете форму, а не когда FDF вернулся. Небольшого хелпера над подпрограммами SHA256Init / SHA256Update / SHA256Final в HPDFCrypt достаточно:
uses
SysUtils, Classes, HPDFCrypt;
function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
Stream: TFileStream;
Ctx: TSHA256Context;
Digest: TSHA256Digest;
Buffer: array[0..65535] of Byte;
Remaining: Int64;
Count, I: Integer;
begin
Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
raise EArgumentException.Create('Base length is outside the file');
SHA256Init(Ctx);
Remaining := PrefixBytes;
while Remaining > 0 do
begin
Count := SizeOf(Buffer);
if Remaining < Count then
Count := Integer(Remaining);
Stream.ReadBuffer(Buffer, Count);
SHA256Update(Ctx, Buffer, Count);
Dec(Remaining, Count);
end;
SHA256Final(Ctx, Digest);
finally
Stream.Free;
end;
Result := '';
for I := 0 to High(Digest) do
Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;
// В момент отправки: весь исходящий файл — базовая редакция
// BaseLength := размер 'out\contract-0042.pdf' в байтах
// BaseHash := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);
Загруженный документ не обязан быть ровно базой. Это может быть база плюс редакции, которые вы уже зафиксировали из предыдущих подач, — так накопительный поток остаётся управляемым: BaseBytes продолжает называть исходный префикс, а всё после него трактуется как ранее добавленные байты
Применение Differences через SaveFDFDifferencesToFile
THPDFHeadlessDocument.SaveFDFDifferencesToFile принимает имя выходного файла, байты FDF, длину и хеш базы, необязательный выбор вложенного FDF и лимитную запись THPDFFDFPackageOptions, а возвращает THPDFHeadlessFDFDifferencesResult. Внутри он открывает FDF как THPDFFDFPackage и сам вызывает DecodeDifferences, так что вы передаёте FDF-файл, а не заранее декодированный поток. Пустой Selection означает верхнеуровневый FDF; непустой сперва спускается во вложенный дочерний FDF
uses
SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;
procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
BaseLength: Int64; const BaseHash: AnsiString);
var
Doc: THPDFHeadlessDocument;
Policy: THPDFHeadlessCMSOptions;
Selection: THPDFFDFChildSelections;
Applied: THPDFHeadlessFDFDifferencesResult;
begin
Doc := THPDFHeadlessDocument.Create;
try
Policy := THPDFHeadlessCMSOptions.Default;
Policy.TrustedCAFile := 'trust\issuing-ca.pem';
Doc.SetSignaturePolicyOptions(Policy);
Doc.LoadFromFile(CurrentPdf);
Selection := nil; // верхнеуровневый FDF, без вложенного потомка
Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
[Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
finally
Doc.Free;
end;
end;
Порядок проверок важнее самого вызова. После прохода хеша базы метод сравнивает уже зафиксированные добавленные байты — всё между BaseBytes и концом загруженного файла — с началом декодированной нагрузки. Подача, короче того, что вы уже зафиксировали, проваливается с "A cumulative submission cannot remove committed append bytes", а расходящаяся с ним — с "The cumulative Differences conflict with the committed revision". Новым является только оставшийся суффикс, и только он попадает в AddedBytes
Собранный кандидат пишется во временный файл с монопольным доступом в целевом каталоге, сбрасывается на диск, заново открывается как независимый источник редакции и валидируется, прежде чем заменить вывод. Сбои разбора, отмены, политики, бюджета или публикации оставляют и загруженный документ, и любой существующий выходной файл нетронутыми. Два предусловия ловят людей рано: вывод не должен совпадать с загруженным источником, а в документе не должно быть несохранённых правок, потому что непрозрачная добавка молча их выбросит ("Publish pending document edits before applying Differences")
Почему повторное применение — no-op с точностью до байта?
Повторное применение того же накопительного FDF — no-op, потому что каждый байт его нагрузки уже в файле. Когда нагрузка равна ранее добавленным байтам в точности, SaveFDFDifferencesToFile сообщает NoOp = True и AddedBytes = 0, а публикуемый вывод идентичен текущему PDF. Это гарантия сильнее, чем «только добавляй», и именно она делает безопасными повторы после сетевого таймаута или падения воркера
// Редакция 1 уже зафиксирована в contract-0042-r1.pdf; тот же FDF приходит дважды
// (Doc, Again, Fdf, Selection, BaseLength и BaseHash объявлены выше)
Doc := THPDFHeadlessDocument.Create;
try
Doc.LoadFromFile('store\contract-0042-r1.pdf');
Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
// Again.NoOp = True, Again.AddedBytes = 0,
// а retry-файл побайтово идентичен contract-0042-r1.pdf
finally
Doc.Free;
end;
Приёмочные тесты пишите под это. Утверждайте полное побайтовое равенство между retry-выводом и текущей редакцией, а не просто то, что файл не похудел. Тест, проверяющий лишь поведение «только добавляй», с радостью пропустит реализацию, добавляющую ту же редакцию второй раз, — она даёт правдоподобный на вид PDF с дублированной секцией обновления, которую потом приходится объяснять аудиту DocMDP
Какие проверки допуска идут до публикации редакции?
До публикации кандидат PDF должен пройти проверки идентичности, подписей и разрешений, и ни одна из них не доверяет внешним полям FDF. Первый элемент trailer /ID обязан сохраниться без изменений, и каждое поле подписи, уже присутствующее в загруженном документе, обязано существовать с идентичным словарём подписи и той же идентичностью значения подписи. Нагрузка Differences, удаляющая, заменяющая или переписывающая существующую подпись, проваливается с hdSignatureModificationUnsupported
Дальше идут слои авторизации. Для непустой добавки в зашифрованном документе изменённые объекты классифицируются по роли (каталог и страницы, аннотации, поля AcroForm, appearance stream) и сверяются с аутентифицированными битами разрешений пользователя, поэтому редакция, правящая аннотации, нуждается в разрешении на аннотации, и так далее. Затем кандидат проходит то же принуждение сертификации и блокировок полей, которое HotPDF Component применяет и в других местах: сертификационная подпись DocMDP (ISO 32000-1 §12.8.2.2) ограничивает, какие виды изменений могут делать последующие редакции, а блокировки FieldMDP (§12.8.2.4) защищают именованные поля. Анализ редакций DocMDP и FieldMDP в HotPDF рассказывает, как эти разрешения классифицируются по редакциям
Обработку ошибок стоит разделить на «этот FDF кривой» и «эта редакция не разрешена», потому что второй случай обычно требует человека:
try
Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
except
on E: EHPDFFDFPackageError do
QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
on E: EHPDFHeadlessDocumentError do
case E.Code of
hdPermissionDenied, hdSignatureModificationUnsupported:
EscalateForReview(FdfFile, E.Message);
hdLimitExceeded:
QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
else
QuarantineFdf(FdfFile, E.Message); // несовпадение базы, конфликт, плохой конец
end;
end;
Чего операция никогда не делает, так же важно, как то, что она проверяет. SaveFDFDifferencesToFile никогда не забирает файловую спецификацию /F из FDF, поэтому PDF, названный внутри FDF, не может перенаправить слияние на другой файл на диске или в сети, и она никогда не исполняет JavaScript или actions. Каждый вход приходит явно: загруженная база, байты FDF и настроенный вами материал доверия
Транзакции Windows и C ABI fdf.differences.apply
Тот же транспорт доступен и без прямого написания headless-кода. THPDFWindowsFormDataTransaction.ApplyFDFDifferences принимает байты FDF, длину и SHA-256 базы, выбор, опции пакета и пароль, делегирует SaveFDFDifferencesToFile и добавляет байтовый учёт, откат, отмену и поведение повтора на том же хендле, присущие транзакции. Для недельфийских вызывающих C ABI выставляет JSON-операцию fdf.differences.apply, где base несёт длину и хеш:
{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
"base": { "bytes": 673, "sha256": "..." },
"signaturePolicy": { "trustedCAPEM": "..." } }
JSON-ответ сообщает baseBytes, priorAppendBytes, totalAppendBytes, addedBytes и noop — те же поля, что и THPDFHeadlessFDFDifferencesResult. Опубликованные приёмочные ворота этого транспорта гоняются на Linux FPC 3.3.1, Windows Delphi 32- и 64-бит и Windows FPC 32- и 64-бит; каждая цель безопасности покрывает 21 случай с 1 244 нативными проверками и 382 командами колбэков
Пределы и грабли фикстур, о которых стоит знать
Транспорт Differences нарочно узок, и пара его краёв уже кусала реальные наборы тестов:
- База обязана быть полной PDF-редакцией. Длина, попавшая в середину файла, или чей
startxrefне указывает на её реальные данные перекрёстных ссылок, отвергается раньше, чем будет скопирован хоть байт - Подписным фикстурам нужен косвенный
/Lock. Читатель разрешений загруженного документа в Windows отвергает поле подписи, чей/Lock— встроенный словарь, с "Signature field /Lock must be an indirect reference", хотя некоторые пути подписания прощают встроенную форму, так что пишите словарь блокировки собственным объектом - Тестовый CA, сделанный голым
openssl req -x509, не имеетCA:TRUEиkeyCertSign, поэтому валидация цепочки проваливается с certificate verify error, похожим на баг библиотеки, но им не являющимся - Внешние значения
/Fieldsэтот вызов не смёржит. Если они вам тоже нужны в документе, решите явно, информационные они или нет, и никогда не подавайте их как подписанные - Сам FDF остаётся неинкрементальным. FDF-файл с несколькими trailer, ненулевыми поколениями или дублированными номерами объектов — кривой FDF, а не более богатый
Для простой стороны значений FDF и XFDF, без подписанных редакций в кадре, лучшими стартовыми точками будут туда-обратно multi-select FDF и XFDF в HotPDF и импорт и экспорт аннотаций XFDF в Delphi
FAQ
Можно передать декодированный поток Differences вместо FDF-файла?
Нет. SaveFDFDifferencesToFile разбирает переданный FDF как THPDFFDFPackage, применяет необязательный выбор вложенного и вызывает DecodeDifferences внутри. Используйте THPDFFDFPackage.DecodeDifferences отдельно, когда хотите осмотреть или заархивировать сырые байты редакции, но в вызов применения передавайте FDF-файл
Что будет, когда клиент шлёт второй, больший накопительный FDF?
Загрузите PDF, уже содержащий первую зафиксированную редакцию, и держите те же BaseBytes и BaseSHA256. Метод проверит, что новая нагрузка начинается ровно с уже зафиксированных байтов, добавит только новый суффикс, сообщит его в AddedBytes, а затем прогонит полную проверку разрешений и допуск DocMDP/FieldMDP по результату
Значит ли успешное применение, что значения в /Fields подписаны?
Нет. ISO 32000-1 не требует, чтобы /Fields, /Annots и /Differences в одном FDF совпадали, и HotPDF Component их не сверяет. Подписано то, что покрывают CMS-подписи в добавленной редакции, поэтому подписанные значения читайте из смёрженного PDF, а не из FDF-обёртки
Транспорт FDF Differences, headless-движок политик подписи и транзакции form-data Windows поставляются в составе HotPDF Component для Delphi, C++Builder и Free Pascal