Articolo tecnico

Come i PDF Portfolio salvano colonne, ordinamento e cartelle

Tre offerte entrano in un PDF Portfolio, e la colonna Amount decrescente del viewer colloca l'offerta da 104.250 USD per ultima, sotto quella da 9.800 USD — gli importi sono entrati come testo. I valori di colonna vivono nella voce /CI di ogni file allegato, e SetPortfolioItemNumericMetadata di HotPDF li scrive lì come numeri PDF, così il sort confronta 104250 con 9800 invece che due stringhe

Cosa fa aprire un PDF come Portfolio?

Lo decide una chiave sola del catalogo. ISO 32000-1 §12.3.5 dice che quando è presente un dizionario /Collection, un reader conforme deve presentare il documento come portable collection (il nome della specifica per quello che Acrobat chiama PDF Portfolio), e ogni file nell'albero dei nomi /Names /EmbeddedFiles è un membro; gli allegati fuori da quell'albero non lo sono. Un PDF con dieci allegati e nessun /Collection è solo un PDF con un pannello allegati affollato, che è la forma giusta quando viaggia con il documento un solo file leggibile dalla macchina, come fa l'XML in una fattura elettronica ZUGFeRD o Factur-X. Il dizionario è piccolo e quasi interamente opzionale; i numeri di tabella sono cambiati fra le due edizioni, quindi li elenco entrambi:

VoceCosa controllaISO 32000-1ISO 32000-2
/View, /DVista iniziale (D dettagli, T tile, H nascosta, più C per un navigator nel PDF 2.0) e la chiave nell'albero dei nomi del file mostrato per primoTabella 155Tabella 153
/SchemaLe colonne dell'elenco dei fileTabelle 156, 157Tabelle 154, 155
/SortCampi e direzioni di ordinamentoTabella 158Tabella 156
/ColorsPalette DeviceRGB suggerita per il layoutnessunaTabella 157
/SplitDirezione e posizione della barra di divisionenessunaTabella 158
/FoldersRadice dell'albero delle cartellenessunaTabella 159 (§12.3.5.2)

Le pagine del contenitore contano ancora, perché sono la copertina. Quando manca /D, il documento iniziale è il contenitore stesso; ISO 32000-2 aggiunge che un /D che non nomina nessun file esistente porta il reader a selezionare il primo file dell'elenco. Un viewer senza supporto portfolio ignora /Collection e mostra solo quelle pagine, e la NOTE 2 del §12.3.5 chiede lì un titolo e un paragrafo introduttivo; la risposta consueta è una pagina che dice cosa contiene il pacchetto e che serve un viewer capace di portfolio. ConfigurePortfolio(Name, View, InitialDocument) imposta vista e /D in una chiamata sola, e ConfigurePortfolioView passa in seguito fra pvmDetails, pvmTile e pvmHidden. La vista C richiede un dizionario navigator (ISO 32000-2 Tabella 160); per un pacchetto di offerte o un set di consegna, la vista dettagli con un ordinamento sensato è ciò che gli utenti si aspettano

Campi dello schema e i valori dietro ogni colonna

Le colonne vengono dal dizionario schema della collection, un dizionario collection field per ogni chiave che scegliete. Gran parte di un campo è presentazione — /N è l'intestazione, /O la posizione della colonna, /V e /E la visibilità iniziale e la modificabilità, passate a THotPDF.AddPortfolioSchemaField come DisplayName, Order, Visible e Editable. Il /Subtype è la decisione con conseguenze: dice chi fornisce il valore:

/SubtypeCostante HotPDFIl valore viene da
S, D, NcfsText, cfsDate, cfsNumberVoi, per file, in /CI
F, DesccfsFileName, cfsDescriptionLa specifica del file (/UF o /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeIl /Params del file incorporato
CompressedSize (PDF 2.0)cfsCompressedSizeLa /Length dello stream incorporato

I valori per file stanno nel dizionario collection item a cui punta la voce /CI della specifica del file (ISO 32000-1 §7.11.6 Tabella 48, Tabella 46 nella parte 2). Ogni voce è una stringa di testo, una data, un numero, oppure un dizionario collection subitem (Tabella 49, o Tabella 47 nella parte 2) il cui /D tiene i dati e il cui /P tiene un prefisso di visualizzazione che l'ordinamento ignora. L'Esempio 2 della specifica mostra perché il prefisso esiste: un oggetto di email conservato come /D (Let's have lunch on Friday!) con /P (Re:), così una risposta ordina accanto al messaggio a cui risponde. Stranamente, entrambe le edizioni dicono che lo schema di quell'esempio è in §12.4.2, Page Labels; intendono l'Esempio 1 subito sopra. Quanto alle colonne legate al file, AddDocumentAttachment timbra le date di /Params col momento dell'incorporamento, quindi una colonna cfsModDate mostra quando è stato costruito il pacchetto. Chi revisiona e ha bisogno della data del file sorgente si prende una colonna D da riempire voi, come received più sotto

Schema di PDF Portfolio HotPDF in Delphi: i subtype cfsFileName, cfsText, cfsNumber, cfsDate e cfsSize di AddPortfolioSchemaField mappati sulla specifica del file, sul dizionario collection item /CI e sul /Params del file incorporato
Le colonne testo, data e numero prendono i propri valori dalla voce /CI di ogni file, mentre le colonne nome file, descrizione, data e dimensione arrivano da strutture scritte quando il file viene incorporato
uses
  System.SysUtils, HPDFDoc;

procedure BuildBidPackage;
const
  Files: array[0..2] of string =
    ('bids\civil.pdf', 'bids\electrical.pdf', 'bids\hvac.pdf');
  Trades: array[0..2] of AnsiString = ('Civil', 'Electrical', 'HVAC');
  Amounts: array[0..2] of Double = (104250.0, 98500.0, 9800.0);
var
  Pdf: THotPDF;
  SortKeys: THPDFPortfolioSortKeyArray;
  Key: AnsiString;
  Stamp: TDateTime;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf17;
    Pdf.FileName := 'bid-package.pdf';
    Pdf.BeginDoc;
    // Copertina: tutto ciò che mostrerà un viewer senza supporto portfolio
    Pdf.CurrentPage.SetFont('Arial', [], 16);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Riverside depot - bid package');
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 736, 0,
      'Three bids are attached. Open this PDF Portfolio in a portfolio-capable viewer.');

    Pdf.ConfigurePortfolio('', 'D', 'civil.pdf');
    Pdf.AddPortfolioSchemaField('file', 'File', cfsFileName, 0);
    Pdf.AddPortfolioSchemaField('trade', 'Trade', cfsText, 1);
    Pdf.AddPortfolioSchemaField('amount', 'Amount', cfsNumber, 2);
    Pdf.AddPortfolioSchemaField('received', 'Received', cfsDate, 3);
    Pdf.AddPortfolioSchemaField('size', 'Size', cfsSize, 4, False); // nascosta all'inizio

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // la chiave dell'albero dei nomi
      Pdf.SetPortfolioItemMetadata(Key, 'trade', Trades[I]);
      Pdf.SetPortfolioItemNumericMetadata(Key, 'amount', Amounts[I], 'USD ');
      if FileAge(Files[I], Stamp) then
        Pdf.SetPortfolioItemMetadata(Key, 'received',
          AnsiString('D:' + FormatDateTime('yyyymmddhhnnss', Stamp)));
    end;

    SetLength(SortKeys, 2);
    SortKeys[0].Key := 'amount';
    SortKeys[0].Ascending := False;   // l'offerta più grande in cima
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // per spareggio
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata e SetPortfolioItemNumericMetadata sollevano eccezioni quando la chiave dello schema non è registrata o nessun file incorporato ha la chiave data, quindi registrate lo schema, allegate il file, poi impostate i valori. Il loro argomento FileName è la chiave dell'albero dei nomi, che per AddDocumentAttachment è il nome del file senza directory. Le chiavi sono stringhe di byte confrontate con distinzione maiuscole/minuscole (§7.9.6), come per le named destinations nell'albero /Dests, quindi Civil.pdf non troverà civil.pdf. Quando due file sorgente condividono il nome di base, HotPDF tiene entrambi e dà al secondo una chiave numerata tipo hvac (2).pdf, che le chiamate di metadati dovranno allora usare

Come finisce un'offerta da 104.250 sotto una da 9.800?

È stato conservato come testo. Le regole di ordinamento del §12.3.5 dipendono dal tipo di dati — i numeri numericamente, le date dalla più vecchia alla più recente, le stringhe di testo lessicalmente, con la NOTE 3 che lascia i dettagli dell'ordine lessicale a ogni reader. La Tabella 48 (Tabella 46 nella parte 2) richiede che ogni voce /CI corrisponda al /Subtype del suo campo, quindi "USD 104,250" scritto come testo in una colonna N viola già la regola. Al reader resta il confronto fra stringhe: dopo il "USD " comune, il carattere 1 viene prima di 9, e un sort discendente mette l'offerta più grande per ultima. SetPortfolioItemMetadata scrive sempre un /D di testo, giusto per i campi S e per le stringhe data. Per i campi N, SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) scrive un numero PDF e porta il simbolo della valuta in /P, che il sort ignora. Tenete unità e simboli di valuta nel prefisso su ogni colonna numerica: il valore che sembra numerico solo sullo schermo è proprio quello che ordina male. Con prefisso vuoto e UseSubitemDictionary a False, il numero va dritto in /CI

SetPortfolioItemNumericMetadata di HotPDF in Delphi scrive gli importi del PDF Portfolio come numeri con prefisso /P USD, così un sort discendente elenca prima 104250, a differenza delle stringhe di testo che mettono USD 104,250 sotto USD 9,800
Conservata come stringa, l'offerta da 104.250 atterra sul fondo di una colonna Amount discendente; conservata come numero con la valuta in /P, risale in cima

Le date seguono la stessa logica. Un campo D si aspetta una stringa data PDF (§7.9.4) tipo D:20261031143000; il 31/10/2026 preso dalle impostazioni locali di Windows è testo in una colonna data e atterra dove cadono i suoi caratteri. Il sort in sé vive nel dizionario sort della collection: /S è un nome di campo o un array in cui ogni nome successivo spezza i pareggi lasciati dai precedenti, e /A è un booleano o un array parallelo di booleani, con le voci in più ignorate e quelle mancanti lette come true, cioè ascendente. SetPortfolioSortOrder prende un array di record THPDFPortfolioSortKey e scrive la forma a nome singolo per una chiave, quella ad array per più chiavi. Usate le chiavi registrate nello schema; /S può nominare solo campi descritti nel dizionario collection genitore

Le cartelle PDF 2.0 vivono nei nomi dei file

Una cartella non contiene file dentro il PDF. ISO 32000-2 §12.3.5.2 definisce i dizionari cartella come oggetti indiretti collegati in un albero: /Folders punta alla radice, ogni figlio porta /Parent, una cartella raggiunge il suo primo figlio tramite /Child, e i fratelli si incateno tramite /Next. Ogni cartella ha un /Name obbligatorio e un /ID intero non negativo unico nel documento, e l'array /Free della radice elenca gli intervalli di ID ancora inutilizzati. L'appartenenza è codificata nella chiave EmbeddedFiles: una che inizia con <, continua con cifre e chiude con > appartiene alla cartella con quell'ID, e il resto è il nome del file, quindi <3>invoice-1031.pdf è invoice-1031.pdf nella cartella 3. La chiave è la cartella. Qualsiasi chiave fuori dalla convenzione appartiene alla radice, ed è così che fatture destinate a una cartella 2026 finiscono al livello più alto — allegate con AddDocumentAttachment semplice, le loro chiavi non portano alcun prefisso

HotPDF costruisce l'albero con tre chiamate. EnsureCollectionRootFolder(RootName, Description) crea la radice con ID 0, oppure restituisce l'ID della radice esistente. AddCollectionChildFolder(ParentFolderID, FolderName, Description) accoda un figlio, restituisce il suo nuovo ID e tiene /Free aggiornato. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) incorpora il file sotto <FolderID>name e scrive il nome nudo in /F e /UF, che è ciò che mostra una colonna cfsFileName. Ogni chiamata successiva che nomina il file ha bisogno della chiave completa, incluso InitialDocument in ConfigurePortfolio e le chiamate di metadati. Cartelle, /Split e /Colors sono voci del PDF 2.0, quindi impostate Version a pdf20 prima di BeginDoc

Cartelle PDF 2.0 del Portfolio con HotPDF in Delphi: EnsureCollectionRootFolder e AddCollectionChildFolder costruiscono l'albero delle cartelle /Child e /Next, e AddDocumentAttachmentToCollectionFolder scrive chiavi EmbeddedFiles che portano il prefisso dell'ID cartella
L'appartenenza a una cartella in una portable collection PDF 2.0 è codificata nel prefisso di cifre di ogni chiave EmbeddedFiles, e qualsiasi chiave senza cifre fra parentesi angolari resta alla radice
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders, /Split e /Colors sono PDF 2.0
    Pdf.FileName := 'handover.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 16);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Project handover - PDF Portfolio');

    Pdf.ConfigurePortfolio('', 'D', '<0>handover-summary.pdf');
    Pdf.AddPortfolioSchemaField('file', 'File', cfsFileName, 0);
    Pdf.AddPortfolioSchemaField('amount', 'Amount', cfsNumber, 1);

    RootID := Pdf.EnsureCollectionRootFolder('Handover', 'All deliverables'); // 0
    Year2025 := Pdf.AddCollectionChildFolder(RootID, '2025');                 // 1
    Year2026 := Pdf.AddCollectionChildFolder(RootID, '2026');                 // 2
    Q4 := Pdf.AddCollectionChildFolder(Year2026, 'Q4 invoices', 'Oct-Dec');   // 3

    Pdf.AddDocumentAttachmentToCollectionFolder(RootID,
      'out\handover-summary.pdf', 'Read this first');  // <0>handover-summary.pdf
    Pdf.AddDocumentAttachmentToCollectionFolder(Year2025,
      'out\as-built-2025.pdf', 'As-built drawings');   // <1>as-built-2025.pdf
    Pdf.AddDocumentAttachmentToCollectionFolder(Q4,
      'out\invoice-1031.pdf', 'October invoice');      // <3>invoice-1031.pdf

    Pdf.SetPortfolioItemNumericMetadata(
      AnsiString(Format('<%d>invoice-1031.pdf', [Q4])), 'amount', 10400.0, 'USD ');

    Pdf.SetPortfolioSplit(csdVertical, 30);    // splitter al 30 % della finestra
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

L'ordine fra fratelli è una raccomandazione dello standard: i fratelli dovrebbero seguire la chiave di sort della collection, o i nomi delle cartelle se non c'è. AddCollectionChildFolder collega i fratelli in ordine di chiamata, quindi createli già ordinati, come fa l'esempio con 2025 prima di 2026. La posizione di /Split (ISO 32000-2 Tabella 158) è una percentuale da 0 a 100, da qui 30 e non 0.3. Le regole sui nomi dopo la Tabella 159 valgono per i nomi delle cartelle e per i file dentro le cartelle, e siccome quei nomi di solito arrivano da utenti o form di upload, filtrateli prima della chiamata; AddCollectionChildFolder in persona rifiuta barre, caratteri di controllo e un nome di fratello che ne ripete uno esistente ignorando il maiuscolo/minuscolo ASCII:

  • fra 1 e 255 caratteri, senza NUL incorporati
  • nessuno dei caratteri / \ : * " < > | da qualsiasi parte nel nome
  • nessun punto come ultimo carattere
  • niente due cartelle fratelli, né due file nella stessa cartella, i cui nomi differiscano solo per maiuscole/minuscole

Leggere un Portfolio scritto da un altro producer

Un reader deve accettare ogni forma legale di /CI, perché i producer differiscono: alcuni avvolgono ogni valore in un dizionario subitem, altri scrivono stringhe e numeri nudi, e l'Esempio 2 della specifica mescola entrambi dentro un solo item. Codice che cerca solo una stringa sotto il /D di un subitem mostra colonne vuote per il secondo gruppo. GetLoadedCollectionItemFields(FileName) restituisce un THPDFCollectionItemFieldValue per voce con IsSubitemDictionary, IsNumeric, NumericValue, StringValue e Prefix compilati. GetLoadedCollectionInfo restituisce schema, chiavi di sort, split, colori e i nodi cartella di GetLoadedCollectionFolderTree in ordine di albero, ciascuno con ID, Name, ParentID, FirstChildID e NextSiblingID. La cartella di ogni file viene dalla sua chiave, e il parse deve seguire la regola alla lettera: solo cifre fra le parentesi angolari. Una chiave tipo <draft>notes.txt, venuta da un producer che di cartelle non sa nulla, non ha cifre lì, quindi il file appartiene alla radice; tagliare tutto fino alla prima > inventerebbe una cartella di nome draft

procedure DumpPortfolio(const PdfFile: string);
var
  Pdf: THotPDF;
  Info: THPDFCollectionInfo;
  Keys: THPDFAnsiStringArray;
  Fields: THPDFCollectionItemFieldValueArray;
  BareName: AnsiString;
  I, J, FolderID: Integer;
  Line: string;

  // ISO 32000-2 12.3.5.2: '<' cifre '>' nome file; qualunque altra chiave è radice
  function FolderOfKey(const Key: AnsiString; RootID: Integer;
    out Name: AnsiString): Integer;
  var
    P: Integer;
  begin
    Result := RootID;
    Name := Key;
    if (Length(Key) < 3) or (Key[1] <> '<') then
      Exit;
    P := 2;
    while (P <= Length(Key)) and (Key[P] in ['0'..'9']) do
      Inc(P);
    if (P = 2) or (P > Length(Key)) or (Key[P] <> '>') then
      Exit;
    Result := StrToIntDef(string(Copy(Key, 2, P - 2)), RootID);
    Name := Copy(Key, P + 1, MaxInt);
  end;

begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(PdfFile) <= 0 then
      Exit;
    if not Pdf.GetLoadedCollectionInfo(Info) then
    begin
      Writeln('No /Collection in the catalog: plain attachments only');
      Exit;
    end;
    for I := 0 to High(Info.FolderNodes) do
      Writeln(Format('folder %d "%s" parent %d',
        [Info.FolderNodes[I].ID, Info.FolderNodes[I].Name,
         Info.FolderNodes[I].ParentID]));

    Keys := Pdf.GetLoadedEmbeddedFileNames;
    for I := 0 to High(Keys) do
    begin
      if Info.HasFolderTree then
        FolderID := FolderOfKey(Keys[I], Info.RootFolderID, BareName)
      else
      begin
        FolderID := -1;            // niente cartelle: una lista piatta
        BareName := Keys[I];
      end;
      Line := Format('[%d] %s:', [FolderID, BareName]);
      Fields := Pdf.GetLoadedCollectionItemFields(Keys[I]);
      for J := 0 to High(Fields) do
        if Fields[J].IsNumeric then
          Line := Line + Format(' %s=%s%g',
            [Fields[J].Key, Fields[J].Prefix, Fields[J].NumericValue])
        else
          Line := Line + Format(' %s=%s%s',
            [Fields[J].Key, Fields[J].Prefix, Fields[J].StringValue]);
      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

Atteso per il file di consegna: quattro righe di cartelle con la radice all'ID 0 e Q4 invoices sotto il genitore 2, poi [3] invoice-1031.pdf: amount=USD 10400 riconosciuto come numerico. Fate girare lo stesso ciclo sul pacchetto di offerte e ogni voce amount torna con IsNumeric impostato; un importo che arriva solo in StringValue è proprio quello che ordinerà 104.250 sotto 9.800. Quel controllo sta in poche righe accanto al codice che costruisce pacchetti con il componente HotPDF per Delphi e C++Builder