기술 문서

PDF 포트폴리오는 열과 정렬 키, 폴더를 어떻게 저장할까

입찰 세 건이 PDF Portfolio에 들어가고, 뷰어의 내림차순 Amount 열이 USD 104,250 입찰을 USD 9,800 아래, 맨 끝에 놓습니다 — 금액이 텍스트로 들어갔습니다. 열 값은 첨부된 파일마다의 /CI 항목에 살고, HotPDF의 SetPortfolioItemNumericMetadata는 그곳에 PDF 숫자로 기록하므로 정렬은 두 문자열 대신 104250과 9800을 비교합니다

PDF가 Portfolio로 열리게 만드는 것은?

카탈로그 키 하나가 결정합니다. ISO 32000-1 §12.3.5는 /Collection 사전이 있을 때 표준을 따르는 reader가 문서를 portable collection(명세에서 Acrobat가 PDF Portfolio라 부르는 것의 이름)으로 제시해야 한다고 말하며, /Names /EmbeddedFiles 이름 트리의 모든 파일이 멤버입니다. 그 트리 밖의 첨부는 그렇지 않습니다. 첨부 열 개에 /Collection 없는 PDF는 그냥 첨부 패널이 바쁜 PDF이며, XML이 ZUGFeRD 또는 Factur-X 전자송장에서 그러하듯 기계 판독 가능한 파일 하나가 동승할 때가 바로 알맞은 형태입니다. 사전은 작고 거의 전부 선택적입니다. 두 판본 사이에 표 번호가 이동했으므로 둘 다 적어 둡니다:

항목다루는 것ISO 32000-1ISO 32000-2
/View, /D초기 뷰(D 상세, T 타일, H 숨김, PDF 2.0에서는 내비게이터용 C 추가)와 첫 화면에 보이는 파일의 이름 트리 키Table 155Table 153
/Schema파일 목록의 열들Tables 156, 157Tables 154, 155
/Sort정렬 필드와 방향Table 158Table 156
/Colors레이아웃에 제안하는 DeviceRGB 팔레트noneTable 157
/Split스플리터 바의 방향과 위치noneTable 158
/Folders폴더 트리의 루트noneTable 159 (§12.3.5.2)

컨테이너 자신의 페이지들도 여전히 중요합니다. 그들이 표지 시트이기 때문입니다. /D가 없으면 초기 문서는 컨테이너 자신입니다. ISO 32000-2는 존재하지 않는 파일을 이름지는 /D는 reader가 대신 목록의 첫 파일을 고르게 만든다고 보탭니다. 포트폴리오를 지원하지 않는 뷰어는 /Collection을 무시하고 그 페이지들만 보이며, §12.3.5의 NOTE 2는 그곳에 제목과 소개 문단을 요청합니다. 평범한 답은 꾸러미에 무엇이 들었는지와 포트폴리오를 지원하는 뷰어가 필요하다는 말 한 장입니다. ConfigurePortfolio(Name, View, InitialDocument)는 뷰와 /D를 한 번의 호출로 정하고, ConfigurePortfolioView는 나중에 pvmDetails, pvmTile, pvmHidden 사이를 바꿉니다. View C는 내비게이터 사전이 필요합니다(ISO 32000-2 Table 160). 입찰 꾸러미나 인수인계 세트에는 합리적인 정렬을 얹은 상세 뷰가 사용자가 기대하는 것입니다

스키마 필드와 각 열 뒤의 값들

열은 컬렉션 스키마 사전에서 옵니다. 고른 키마다 하나의 컬렉션 필드 사전입니다. 필드의 대부분은 표시에 관한 것입니다. /N이 헤더, /O가 열 위치, /V와 /E가 초기 표시 여부와 편집 가능 여부이며 THotPDF.AddPortfolioSchemaField에는 DisplayName, Order, Visible, Editable로 전달합니다. 결과가 따르는 결정은 /Subtype입니다. 누가 값을 공급하는지를 말합니다:

/SubtypeHotPDF 상수값의 출처
S, D, NcfsText, cfsDate, cfsNumber당신이 파일마다 /CI에 기록
F, DesccfsFileName, cfsDescription파일 사양(/UF 또는 /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSize임베디드 파일의 /Params
CompressedSize (PDF 2.0)cfsCompressedSize임베디드 스트림의 /Length

파일별 값은 파일 사양의 /CI 항목이 가리키는 컬렉션 아이템 사전에 앉습니다(ISO 32000-1 §7.11.6 Table 48, 2부에서는 Table 46). 각 항목은 텍스트 문자열, 날짜, 숫자, 또는 컬렉션 서브아이템 사전(Table 49, 2부에서는 Table 47)이며 서브아이템 사전은 데이터는 /D에, 정렬이 무시하는 표시 접두사는 /P에 담습니다. 접두사가 존재하는 이유는 명세 자신의 Example 2가 보여 줍니다. 이메일 제목이 /D (Let's have lunch on Friday!)로 저장되고 /P (Re:)가 붙어, 답장이 자기가 답하는 메시지 옆에 정렬되는 식입니다. 묘하게도 두 판본 모두 그 예의 스키마가 §12.4.2 Page Labels에 있다고 말하지만, 그들이 뜻하는 것은 바로 위의 Example 1입니다. 파일 관련 열에 대해 말하면, AddDocumentAttachment는 /Params 날짜를 임베딩 순간으로 찍으므로 cfsModDate 열은 꾸러미가 만들어진 때를 보여 줍니다. 원본 파일 자신의 날짜가 필요한 검토자에게는 아래의 received처럼 직접 채우는 D 열을 주세요

Delphi의 HotPDF PDF Portfolio 스키마: AddPortfolioSchemaField 서브타입 cfsFileName, cfsText, cfsNumber, cfsDate, cfsSize가 파일 사양, /CI 컬렉션 아이템 사전, 임베디드 파일 /Params에 대응하는 모습
텍스트, 날짜, 숫자 열은 각 파일의 /CI 항목에서 값을 가져오는 반면, 파일 이름, 설명, 날짜, 크기 열은 파일이 임베딩될 때 쓰인 구조에서 옵니다
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;
    // 표지 시트: 포트폴리오를 지원하지 않는 뷰어가 보게 될 전부입니다
    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); // 처음에는 숨김

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // 이름 트리 키
      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;   // 최고 입찰가가 맨 위로
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // 동률 결정자
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata와 SetPortfolioItemNumericMetadata는 스키마 키가 등록되지 않았거나 그 키를 지닌 임베디드 파일이 없을 때 예외를 일으킵니다. 그러니 스키마를 등록하고 파일을 첨부한 다음 값을 정하세요. FileName 인자는 이름 트리 키이며 AddDocumentAttachment의 경우 디렉터리를 뺀 파일 이름입니다. 키는 대소문자를 구별해 비교되는 바이트 문자열입니다(§7.9.6). /Dests 트리의 네임드 데스티네이션과 마찬가지로 Civil.pdf는 civil.pdf를 찾지 못합니다. 두 원본 파일이 같은 베이스 이름을 공유하면 HotPDF는 둘 다 지키고 두 번째에 hvac (2).pdf 같은 번호 붙은 키를 주며, 메타데이터 호출은 그 키를 써야 합니다

104,250 입찰은 어떻게 9,800 아래로 떨어질까?

텍스트로 저장됐기 때문입니다. §12.3.5의 정렬 규칙은 데이터 타입에 달립니다. 숫자는 수치로, 날짜는 오래된 것부터, 텍스트 문자열은 사전식으로이며 NOTE 3이 사전식 순서의 세부는 각 reader에 맡깁니다. Table 48(2부에서는 Table 46)은 모든 /CI 항목이 자기 필드의 /Subtype과 맞기를 요구하므로, "USD 104,250"을 텍스트로 N 열에 쓰는 순간 이미 규칙이 깨집니다. reader에게 남는 것은 문자열 비교입니다. 공유된 "USD " 다음에서 문자 1은 9보다 앞서고, 내림차순 정렬은 최고 입찰가를 맨 끝에 놓습니다. SetPortfolioItemMetadata는 언제나 텍스트 /D를 쓰며, S 필드와 날짜 문자열에는 옳습니다. N 필드에는 SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary)가 PDF 숫자를 쓰고 통화 기호를 정렬이 무시하는 /P에 나릅니다. 모든 숫자 열에서 유닛과 통화 기호는 접두사에 두세요. 화면에서만 숫자처럼 보이는 값이 바로 잘못 정렬되는 값입니다. 접두사를 비우고 UseSubitemDictionary를 False로 두면 숫자는 /CI에 곧장 들어갑니다

Delphi의 HotPDF SetPortfolioItemNumericMetadata: 금액을 USD /P 접두사를 얹은 숫자로 기록하므로 내림차순 정렬은 104250을 맨 먼저 놓고, USD 104,250을 USD 9,800 아래로 밀어 넣는 텍스트 문자열과는 다릅니다
문자열로 저장된 104,250 입찰은 내림차순 Amount 열의 바닥에 가라앉고, 통화를 /P에 둔 숫자로 저장되면 맨 위로 올라옵니다

날짜도 같은 논리를 따릅니다. D 필드는 D:20261031143000 같은 PDF 날짜 문자열(§7.9.4)을 기대합니다. Windows 로캘의 31/10/2026은 날짜 열에서는 텍스트이고 그 문자들이 떨어지는 자리면 어디든 갑니다. 정렬 자체는 컬렉션 정렬 사전에 삽니다. /S는 필드 이름 하나 또는 배열이며 배열에서 나중 이름은 앞 이름들이 남긴 동률을 깹니다. /A는 불리언 또는 평행한 불리언 배열로, 남는 항목은 무시되고 빠진 항목은 true, 즉 오름차순으로 읽힙니다. SetPortfolioSortOrder는 THPDFPortfolioSortKey 레코드 배열을 받아 키 하나면 단일 이름 형태를, 여러 개면 배열 형태를 씁니다. 스키마에 등록한 키를 쓰세요. /S는 부모 컬렉션 사전이 기술한 필드만 이름지을 수 있습니다

PDF 2.0 폴더는 파일 이름 안에 삽니다

폴더는 PDF 안에서 파일을 담지 않습니다. ISO 32000-2 §12.3.5.2는 폴더 사전을 하나의 트리로 이어진 간접 객체로 정의합니다. /Folders가 루트를 가리키고, 각 자식은 /Parent를 지니며, 폴더는 /Child로 첫 자식에 도달하고 형제는 /Next로 사슬을 잇습니다. 모든 폴더에는 필수 /Name과 문서 안에서 유일한 음수가 아닌 정수 /ID가 있고, 루트의 /Free 배열은 아직 쓰이지 않은 ID 범위를 나열합니다. 소속은 EmbeddedFiles 키에 인코딩됩니다. <로 시작하고 숫자로 이어져 >로 닫히는 키는 그 ID의 폴더에 속하며 나머지가 파일 이름입니다. 그래서 <3>invoice-1031.pdf는 폴더 3의 invoice-1031.pdf입니다. 키가 곧 폴더입니다. 관습 밖의 키는 루트에 속하며, 2026 폴더를 위한 송장이 최상위로 끝나는 경로가 이것입니다 — 평범한 AddDocumentAttachment로 첨부된 그들의 키는 접두사를 하나도 지니지 않습니다

HotPDF는 세 호출로 트리를 빚습니다. EnsureCollectionRootFolder(RootName, Description)는 ID 0의 루트를 만들거나 기존 루트의 ID를 반환합니다. AddCollectionChildFolder(ParentFolderID, FolderName, Description)는 자식을 덧붙이고 새 ID를 반환하며 /Free를 최신으로 유지합니다. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description)는 파일을 <FolderID>name 아래 임베딩하고 /F와 /UF에는 맨 이름을 쓰며, cfsFileName 열이 보여 주는 것이 바로 그것입니다. 파일을 이름지는 이후의 모든 호출은 전체 키가 필요합니다. ConfigurePortfolio의 InitialDocument와 메타데이터 호출을 포함해서입니다. 폴더, /Split, /Colors는 PDF 2.0 항목이므로 BeginDoc 전에 Version을 pdf20으로 두세요

Delphi의 HotPDF PDF 2.0 Portfolio 폴더: EnsureCollectionRootFolder와 AddCollectionChildFolder가 /Child와 /Next 폴더 트리를 빚고, AddDocumentAttachmentToCollectionFolder가 폴더 ID 접두사를 지닌 EmbeddedFiles 키를 씁니다
PDF 2.0 portable collection에서 폴더 소속은 각 EmbeddedFiles 키의 숫자 접두사에 인코딩되며, 꺾쇠 괄호 안에 숫자가 없는 키는 루트에 머뭅니다
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders, /Split, /Colors는 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);    // 창의 30 % 지점에 스플리터
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

형제 순서는 표준에서 권고입니다. 형제는 컬렉션 정렬 키를 따르거나, 없으면 폴더 이름을 따라야 합니다. AddCollectionChildFolder는 형제를 호출 순서로 이으므로, 예제가 2025를 2026보다 먼저 만들듯 정렬된 채로 만드세요. /Split 위치(ISO 32000-2 Table 158)는 0부터 100까지의 백분율이므로 0.3이 아니라 30입니다. Table 159 뒤의 이름 규칙은 폴더 이름과 폴더 안 파일에 적용되고, 그 이름들은 대개 사용자나 업로드 폼에서 오므로 호출 전에 걸러 두세요. AddCollectionChildFolder 자신은 슬래시, 제어 문자, 그리고 ASCII 대소문자를 무시할 때 기존 형제 이름과 겹치는 이름을 거부합니다:

  • 1자부터 255자 사이, 안에 박힌 NUL 없음
  • 이름 어디에도 / \ : * " < > | 없음
  • 마지막 문자로 마침표 없음
  • 이름이 대소문자만으로 다른 형제 폴더 둘, 같은 폴더 안의 파일 둘 없음

다른 producer가 쓴 Portfolio 읽기

reader는 /CI의 모든 합법적 형태를 받아들여야 합니다. producer들은 다릅니다. 어떤 이는 값마다 서브아이템 사전을 씌우고 어떤 이는 맨 문자열과 숫자를 쓰며, 명세의 Example 2는 하나의 아이템 안에서 둘을 섞습니다. 서브아이템 /D 아래의 문자열만 찾는 코드는 둘째 무리에게 빈 열을 보여 줍니다. GetLoadedCollectionItemFields(FileName)는 항목마다 하나의 THPDFCollectionItemFieldValue를 반환하며 IsSubitemDictionary, IsNumeric, NumericValue, StringValue, Prefix가 채워집니다. GetLoadedCollectionInfo는 스키마, 정렬 키, 스플리터, 색, 그리고 GetLoadedCollectionFolderTree의 폴더 노드들을 트리 순서로 반환하고 각 노드는 ID, Name, ParentID, FirstChildID, NextSiblingID를 지닙니다. 각 파일의 폴더는 그 키에서 나오며 파싱은 규칙을 문자 그대로 따라야 합니다. 꺾쇠 괄호 사이에는 숫자만. 폴더를 전혀 모르는 producer의 <draft>notes.txt 같은 키는 그곳에 숫자가 없으므로 파일은 루트에 속합니다. 첫 >까지 잘라 버리면 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: '<' 숫자 '>' 파일 이름; 그 밖의 키는 루트
  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;            // 폴더 없음: 평평한 목록
        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;

인수인계 파일의 예상 출력: 루트가 ID 0이고 Q4 invoices가 부모 2 아래인 네 줄의 폴더 줄, 그다음 숫자로 표시된 [3] invoice-1031.pdf: amount=USD 10400. 같은 루프를 입찰 꾸러미에 돌리면 모든 amount 항목이 IsNumeric 켜진 채 돌아옵니다. StringValue로만 도착하는 금액이 바로 104,250을 9,800 아래로 정렬할 값입니다. 이 검사는 Delphi와 C++Builder용 HotPDF 컴포넌트로 꾸러미를 빚는 코드 곁에 몇 줄로 들어맞습니다