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

PDF/A-3 associated файлове и AFRelationship в Delphi

За да прикачите source файл към PDF/A-3 документ от Delphi, PDFium Component записва associated-file верига по PDF 2.0: embedded file stream с MIME /Subtype, file specification, носеща /AFRelationship, и /AF масив, закачен на catalog-а или на страница. InjectAssociateFiles и TPdf.SaveAsWithAssociateFiles строят тази верига в една incremental update, а от v3.121.2 MIME типът се сериализира като единично, коректно escaped PDF име. Остатъкът от поста покрива какво проверява валидатор, едносимволния бъг, който счупи text/plain, и местата, където по-старите версии тихо правеха нещо друго, освен поисканото

Какво всъщност трябва на associated файл в PDF/A-3?

Прикачен файл в PDF/A-3 минава валидацията само когато три обекта се разбират помежду си: embedded file stream-ът декларира /Type /EmbeddedFile плюс MIME /Subtype, речникът file specification (ISO 32000-2 §7.11.3) носи /F, /UF, /EF и /AFRelationship, а нещо в документа реферира тази file specification чрез /AF масив (ISO 32000-2 §14.13). Обикновеното вграждане през дървото /Names /EmbeddedFiles, което прави TPdf.CreateAttachment, изобщо не задава полетата за асоциация. Собственият PDF/A-3b валидационен fixture на PDFium Component прави зависимостта конкретна: преименувате ли само ключа /AFRelationship, файлът проваля точно едно правило в клауза 6.8 на ISO 19005-3; махнете ли само MIME /Subtype, проваля друго правило 6.8; пуснете ли същата прикачена файл в кандидат PDF/A-1b, тя се отхвърля изцяло, защото PDF/A-1 забранява embedded файлове, колкото и подредени да са метаданните

Веригата от три обекта на associated файл в PDF/A-3 в PDFium Component: EmbeddedFile stream с MIME Subtype като application xml, file specification с F, UF, EF и AFRelationship, зададено на Data, и AF масив за нея от catalog-а или от страница — трите обекта, които валидатор проверява, преди клауза 6.8 на ISO 19005-3 да мине
Stream, file specification и AF масив трябва да си съответстват; обикновеното вграждане в name tree на TPdf.CreateAttachment не задава нито едно от полетата за асоциация и никога няма да зададе

Стойността за relationship е частта, която хората обичат да гадаят. TPdfAFRelationship в FPdfAssocFiles мапва по един член на enum към всеки name token, който injector-ът може да излъчи, и само първите пет принадлежат на подмножеството, което ISO 19005-3 разпознава:

  • afSource → /Source: оригиналът, от който е произведен PDF-ът — например текстообработващ файл или spreadsheet
  • afData → /Data: машиночитеми данни, от които е изведено видимото съдържание или които то представлява
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: добавки от PDF 2.0, които са извън подмножеството на PDF/A-3, така че държете ги извън архивния изход

Защо /Subtype /text/plain счупи валидацията?

MIME бъгът беше грешка при токенизация, не дупка в съответствието: преди v3.121.2 injector-ът слепваше string-а на извикващия направо след наклонена черта, произвеждайки /Subtype /text/plain. В PDF синтаксисът втората наклонена черта започва нов name обект (ISO 32000-1 §7.3.5), така че изведнъж stream речникът съдържаше ключа /Subtype, името /text и увиснало допълнително име /plain, което разстрои двойките ключ-стойност. Независим PDF/A валидатор отхвърли файла още при парсването на речника EmbeddedFile, преди изобщо да стигне някое PDF/A правило — затова провалът изглеждаше като повреден файл, а не като липсващо свойство на прикачена файл

Fix-ът прекарва MIME стойността през EscapePdfName, който излъчва /text#2Fplain: едно име, чиято декодирана стойност е text/plain. Escaping-ът е нарочно по-широк от наклонената черта. Всеки байт на 32 или под (space, tab, CR, LF), всеки байт на 127 или над, разделителите ()<>[]{}/% и самият escape символ # стават #XX. Escaping на една черта би оставил друга дупка: MIME string, съдържащ >> или интервал, би могъл да затвори речника рано или да вкара излишни ключове, така че регресионният тест подава враждебна стойност с всеки разделител плюс tab, LF и CR и проверява точния кодиран изход

Защо MIME subtype text slash plain счупи PDF/A-3 парсването в PDFium Component: слепването на стойността след наклонена черта произведе два name обекта — /text като стойност плюс увиснал /plain, който разстрои речника EmbeddedFile — а fix-ът в v3.121.2 прекарва стойността през EscapePdfName, така че /text#2Fplain е едно име, декодиращо до text/plain
Провалът изглеждаше като повреден файл, защото стана при parser-а, преди всяко PDF/A правило; escaped името държи двойките балансирани и валидатора да чете
// Какво записва injector-ът за MIMEType = 'text/plain'
//   преди v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (два имена)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (едно име)
//
// Извикващите винаги подават обикновената MIME стойност. Собствено pre-escaping
// двойно кодира '#', което превръща 'text#2Fplain' в 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Строене на PDF/A-3 файл с InjectAssociateFiles

За PDF/A-3 изход произведете съответстващия базов документ с TPdf.SaveAsPdfAToStream и после викнете InjectAssociateFiles върху този stream; точно този двустъпков pipeline пуска валидационният fixture, преди да мине PDF/A-3b. TPdf.SaveAsWithAssociateFiles е удобната обвивка, но записва през обичайния път на SaveAs с saRemoveSecurity, а не през PDF/A writer-а, така че не добавя XMP идентификацията и output intent, които PDF/A изисква. Обърнете внимание, че record типовете живеят в FPdfAssocFiles и FPdfPdfa, така че и двата unit-а трябва да са в uses клаузата ви. От v3.121.3 FileName и Description вече не е задължително да са чист ASCII: /UF и /Desc се записват като PDF текстови string-ове — печатаем ASCII литерално, а всичко останало като UTF-16BE с byte order mark, докато legacy името /F е винаги преносим печатаем ASCII с всеки друг символ заменен от _, така че reader-и, декодирали /F със собствената си кодова страница, показват подчертавка вместо mojibake. По-старите build-ове конвертираха и трите през системната ANSI кодова страница на Delphi или записваха сурови UTF-8 байтове на Free Pascal, така че дръжте имената ASCII само ако по-стари build-ове трябва да дават същия изход

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfPdfa, FPdfAssocFiles;

procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
  PdfAOptions: TPdfASaveOptions;
  Options: TAssocFilesOptions;
  Base: TMemoryStream;
  Output: TFileStream;
begin
  PdfAOptions := TPdfASaveOptions.Default;
  PdfAOptions.Conformance := pac3b;

  Options := TAssocFilesOptions.Default;      // TargetPage = 0: /AF на ниво catalog
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'invoice-data.xml';
  Options.Files[0].Description := 'Structured invoice data';
  Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
  Options.Files[0].Relationship := afData;
  Options.Files[0].MIMEType := 'application/xml';  // записано като /application#2Fxml

  Base := TMemoryStream.Create;
  try
    if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
      raise Exception.Create('PDF/A-3 base save failed');
    Output := TFileStream.Create(OutPath, fmCreate);
    try
      InjectAssociateFiles(Base, Output, Options);  // rewind-ва Base; вдига EPdfAssocFilesError при провал
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Catalog или страница: къде каца /AF масивът?

TAssocFilesOptions.TargetPage решава кой е собственикът на /AF масива: 0 го закача за catalog-а като document-level асоциация, а 1..N — за речника на съответната страница, 1-based. Injector-ът добавя всичко като една incremental update в фиксиран layout (embedded stream-овете, после file specification-ите, после /AF масивът, после пренаписан catalog или page обект), така че съществуващите обекти пазат отместванията си и нищо не се препакова. По-ранен /AF запис на целевия речник се заменя, не се слива, което прави повторния запис idempotent, но значи и че второ извикване с друг списък файлове печели. Два поведения някога заслужаваха проверка в собствения ви код, а и двете са сменили. Преди v3.122.0 TargetPage извън диапазона не се проваляше; падаше обратно на catalog-а, така че печатна грешка превръщаше page-level асоциация в document-level без никакъв сигнал. От v3.122.0 SaveAsWithAssociateFiles и SaveAsWithAssociateFilesToStream вдигат EPdfError, когато TargetPage е извън 0..PageCount, а InjectAssociateFiles вдига новия EPdfAssocFilesError за отрицателен TargetPage или такъв, който не назовава съществуваща страница, оставяйки целевия stream недокоснат. Преди v3.121.4 издирването на страница сканираше записаните байтове за речници /Type /Page в файловия ред, което можеше да закачи файла за друга страница, щом page обектите бяха съхранени в друг ред от показания — например след пренареждане или вмъкване на страници; от v3.121.4 TargetPage назовава страницата на тази позиция в страничния ред на документа

Къде каца AF масивът в PDFium Component: TargetPage нула го закача за catalog-а, страници 1 до N — за речника на страницата, а стойност извън диапазона, която преди v3.122.0 тихо падаше обратно на catalog-а, вече вдига exception, докато injector-ът добавя всичко като една incremental update в фиксиран layout, който пази съществуващите отмествания и заменя всеки по-ранен AF запис
Преди v3.122.0 TargetPage извън диапазона тихо ставаше document-level асоциация; текущите версии вместо това вдигат exception, а второ извикване с друг списък файлове пак печели
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // От v3.122.0 TargetPage извън диапазона вдига EPdfError (по-стари build-ове
  // тихо падаха на /AF на ниво catalog); проверката първо назовава страницата
  if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
    raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);

  Options := TAssocFilesOptions.Default;
  Options.TargetPage := PageNumber;
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'chart-data.csv';
  Options.Files[0].Content := CsvBytes;
  Options.Files[0].Relationship := afSource;
  Options.Files[0].MIMEType := 'text/csv';

  if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
    raise Exception.Create('Associated-file save failed');
end;

Как четете AFRelationship обратно надеждно?

TPdf.AttachmentRelationship[Index] връща името /AFRelationship на прикачена файл чрез нативния export FPDFAttachment_GetAFRelationship, но празен string има две възможни значения, така че първо извикайте AttachmentRelationshipFeaturesAvailable. Обвързката се зарежда снизходително: когато PDFium DLL-ът няма този export, всяка relationship се чете като празна, което е неотличимо от file specification, която просто няма /AFRelationship. Свойството споделя индекса си с AttachmentCount, който брои записите в дървото /Names /EmbeddedFiles. Injector-ът записва само /AF веригата и не добавя запис в name tree, така че файл, прикачен чрез InjectAssociateFiles, е извън този индекс; за да потвърдите инжектираната верига, инспектирайте записаните байтове или пуснете PDF/A валидатор. Вътрешността на това name tree е разгледана в работата с PDF прикачени файлове в Delphi с PDFium Component

procedure ReportRelationships(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Rel: string;
begin
  if not AttachmentRelationshipFeaturesAvailable then
  begin
    Writeln('This PDFium build cannot report /AFRelationship');
    Exit;  // празен отговор би бил двусмислен, така че не питайте
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for I := 0 to Pdf.AttachmentCount - 1 do
    begin
      Rel := Pdf.AttachmentRelationship[I];
      if Rel = '' then
        Rel := '(no /AFRelationship)';
      Writeln(Pdf.AttachmentName[I], ': ', Rel);
    end;
  finally
    Pdf.Free;
  end;
end;

Какво не гарантира SaveAsWithAssociateFiles?

TPdf.SaveAsWithAssociateFiles гарантира файловия формат-плик и че поисканите файлове са инжектирани, не съответствието. Частта за инжектиране е нова: преди v3.122.0, когато записаните байтове нямат четим trailer или речникът catalog не може да бъде намерен, InjectAssociateFiles копираше входа непроменен и методът все пак връщаше True. От v3.122.0 InjectAssociateFiles вдига EPdfAssocFilesError в тези случаи, преди да е записала нещо, SaveAsWithAssociateFiles връща False, а понеже вече строи целия изход в save store, преди да отвори целта, отхвърлен или провален запис вече не отрязва съществуващ файл. Празен Files масив по замисъл и продължава да копира документа непроменен. Съдържанието на payload-а също е ваша отговорност: injector-ът не проверява дали XML файл е well formed, дали MIME типът съвпада с байтовете или дали базовият документ изобщо е PDF/A. Отнасяйте се към крайния файл като към неверифициран, докато някой валидатор го е видял — същата дисциплина, описана в PDFium Component и PDF/A архивното съответствие. Ако сами парсвате входящи речници, същите правила за имена #XX важат обратно — тема, разгледана в капаните на name token при парсване на PDF речници

Associated файлове, PDF/A изход, метаданни на прикачени файлове и валидация пътуват в един и същ компонент, така че pipeline-ът по-горе върви без втора PDF библиотека в build-а. API справочникът, trial изтеглянето и лицензионните опции са на продуктовата страница на PDFium Component