Teknisk artikel

PDF-sidetiketter i Delphi: reparera /Kids-numerträd

PDF Library for Delphi skriver sidetikettintervall med AddPageLabels, och sedan v3.539.10 fungerar anropet också på inlästa filer vars /PageLabels-numerträd är uppdelat i /Kids-noder: roten plattas ut till ett enda /Nums-blad innan det nya intervallet läggs in, så att etiketten faktiskt syns i viewern i stället för att tyst ignoreras. Det typiska offret är en bokliknande PDF från ett layoutverktyg, med romerska siffror i inledningen, arabiska nummer i huvudtexten och ett appendix etiketterat A-1, A-2, där du bara ville etikettera om appendixet och ingenting ändrades

Vad är PDF-sidetiketter och hur lagras de?

Sidetiketter är de strängar en viewer visar i sin sidruta i stället för det fysiska sidindexet, och ISO 32000-1 §12.4.2 lagrar dem som ett numerträd under katalognyckeln /PageLabels. Varje nyckel är ett 0-baserat sidindex som startar ett etikettintervall, och varje värde är en sidetikettordbok med högst tre poster: /S för numreringsstilen (D, R, r, A eller a), /P för en prefixsträng och /St för det numeriska värdet av första sidan i intervallet, som är 1 som standard. Ett intervall löper till nästa nyckel, och specifikationen kräver att trädet innehåller ett värde för sidindex 0, så varje sida täcks av något intervall

Lagring av sidetiketter i PDFlibPas-termer: /PageLabels-numerträdet nycklar varje intervall efter dess 0-baserade startsida, varje värde är en etikettordbok med /S-stil, /P-prefix och /St första nummer, och bokexemplet avbildar romersk inledning, arabiska huvudsidor och ett A--appendix på tre intervall
Ett intervall löper till nästa nyckel, specifikationen kräver ett värde för sidindex 0, och GetPageLabel tillämpar sista intervallet vars nyckel ligger på eller under sidan, så varje sida löser sig till något
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Sidorna 1-4: i, ii, iii, iv (romerska gemener)
    Lib.AddPageLabels(1, 3, 1, '');
    // Sidorna 5-120: 1, 2, 3 ... (decimaler)
    Lib.AddPageLabels(5, 1, 1, '');
    // Sidorna 121 och framåt: A-1, A-2 ... (decimaler med prefix)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) avbildar sina argument på den ordboken utan överraskningar när du väl känner till tre regler. Start är 1-baserat som alla andra sidargument i biblioteket och skrivs in i trädet som Start - 1. Style löper från 0 till 5, där 0 betyder enbart prefix och 1 till 5 blir /S-värdena D, R, r, A och a; allt utanför det intervallet returnerar 0 och rör ingenting. Offset blir /St bara när det är större än noll, så att skicka 0 utelämnar helt enkelt nyckeln och viewern faller tillbaka på standardvärdet 1. Eftersom sidetiketter kom i PDF 1.3 kör anropet också EnsureMinVersion('1.3', '/PageLabels'), som höjer utdataversionen av en äldre fil om du inte uttryckligen låst sparversionen

Varför försvinner nya sidetiketter när trädet har /Kids?

Nya etiketter försvinner för att ISO 32000-1 §7.9.7 (tabell 37) kräver att roten av ett numerträd bär antingen /Kids eller /Nums, aldrig båda, och den tidigare hjälpfunktionen NumTreeSet kunde bara leta efter /Nums. Producenter som skriver ut långa dokument delar ofta upp trädet i intermediära noder, var och en med ett /Limits-par, och hänger dem på en rot som bara har /Kids. Den gamla koden fann ingen /Nums på den roten, skapade en färsk bredvid de befintliga /Kids och lade in det nya intervallet där. Resultatet blev en rot med två ömsesidigt uteslutande ingångar. Viewers går ned genom /Kids och tittar aldrig på den vilsekomna arrayen, bibliotekets egen EnumNumTree kontrollerar också /Kids först, och NumTreeLookup vägrar en nod där HasKids xor HasNums är falskt. AddPageLabels returnerade fortfarande 1 och den sparade filen öppnades fortfarande rent, vilket är den sämsta typen av fel: ingenting klagar, etiketterna blir bara desamma

Fixen i NumTreeSet omvandlar roten till ett blad innan något läggs in. När roten bär /Kids går EnumNumTree igenom varje blad i ordning och samlar in varje nyckel- och värdepar, en ny platt /Nums-array byggs ur den listan, och /Kids, /Limits och eventuella inaktuella /Nums rensas från roten innan den platta arrayen fästs. Att tappa /Limits är inte kosmetiskt, eftersom tabell 37 tillåter den posten bara på intermediära noder och blad, aldrig på roten. Från den punkten är insättningen en vanlig sorterad insättning i en array, och befintliga intervall överlever med sina ursprungliga etikettordböcker. Avvägningen är medveten: trädet byggs inte om i balanserade /Kids-noder efteråt. För sidetiketter kostar det ingenting, för att även en stor referensmanual sällan har fler än några dussin intervall, och ett enda blad är vad de flesta producenter skriver ändå

Reparation av numerträd i PDFlibPas: en rot som bär /Kids och en vilsekommen /Nums-array är osynlig för viewers eftersom ISO 32000-1 bara tillåter en av de två, så NumTreeSet plattar ut varje blad till en enda /Nums-array och rensar /Kids och /Limits, vilket tabell 37 aldrig tillåter på en rot
Ingenting klagade för att varje kontroll gick igenom: AddPageLabels returnerade 1, den sparade filen öppnades rent, och bara en läsare som går ned genom /Kids först, som både viewers och biblioteket självt gör, hittar aldrig det nya intervallet
// Etikettera om appendix i en fil vars /PageLabels-rot använder /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // t.ex. A-1
  // Ersätt intervallet som börjar på sida 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Befintliga romerska och decimala intervall finns kvar i det utplattade bladet
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, oförändrat
end;

Hur kan en /Nums-array feltolkas som nycklar?

En /Nums-array feltolkas när kod går igenom den ett element i taget, för att arrayen är ett platt lopp av alternerande par, [key0 value0 key1 value1 ...], och bara de jämna positionerna är nycklar. Den gamla NumTreeSet-loopen testade varje element för numerisk typ, så ett värde som råkade vara ett tal jämfördes som om det vore en nyckel; en mindre-än-träff kunde sätta insättningspunkten till ett udda index och tappa det nya paret mitt i ett befintligt, vilket skiftade varje senare par ur fas. EnumNumTree hade samma enstegsgenomgång. Båda itererar nu par med steg två, och läser nyckeln vid X * 2 och värdet vid X * 2 + 1, och en exakt nyckelmatchning ersätter värdet och avslutar med Break. Ärligt talat är sidetikettvärden ordlistor, så den andra buggen utlöstes sällan på /PageLabels självt, men en numerträdshjälpfunktion som läser fel steg är korrupt i samma stund ett värde är numeriskt, och den fixades i samma omgång

Parstegsfix i PDFlibPas numerträd: en /Nums-array är ett platt lopp av alternerande nyckel- och värdeposter, så en genomgång som testar varje element kunde lägga in ett nytt par vid ett udda index och skifta senare par ur fas, medan den fixade genomgången läser nyckeln vid X*2 och värdet vid X*2+1
Buggen utlöstes sällan på /PageLabels eftersom etikettvärden är ordlistor, men en numerträdshjälpfunktion som läser fel steg blir korrupt i samma stund ett värde är numeriskt, så båda genomgångarna stegar nu i par

Läsa tillbaka etiketter och skicka dem på rundtur

TPDFlib.GetPageLabel(Page) returnerar etiketten för en 1-baserad sida och har två reservvägar värda att känna till. Utan någon /PageLabels-post alls returnerar den det decimala sidnumret, så en anropare kan använda den villkorslöst. Med ett träd på plats men inget intervall som täcker sidan returnerar den en tom sträng, vilket är exakt vad som händer när en fil hoppar över den obligatoriska index 0-posten; referensdokumentationen säger att ett intervall som börjar på sida 1 måste finnas för att etiketter ska visas korrekt, och koden gör det kravet synligt. Bokstavsstilarna följer specifikationen i stället för kalkylbladskolumner: efter Z kommer AA, sedan BB, som upprepar bokstaven i stället för att stega vidare

var
  P: Integer;
  Data: WideString;
begin
  // Snabb koll på vad en viewer visar i sin sidruta
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Alternativvärde 4 exporterar bara etikettintervall som PageLabelBegin-poster
  Data := Lib.ExportDocumentData(4);
  // Importen spelar upp dem genom ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

För bulkredigeringar skriver ExportDocumentData med alternativvärde 4 varje intervall som ett PageLabelBegin-block med PageLabelNewIndex-, PageLabelStart-, PageLabelPrefix- och PageLabelNumStyle-rader, och ImportDocumentData behandlar den första etikettposten den ser som en full ersättning: den anropar ClearPageLabels en gång och matar sedan varje post till AddPageLabels. Det gör en textbaserad rundtur deterministisk även när originalfilen använde ett /Kids-träd, för att rensningen tar bort hela katalogposten och det återbyggda trädet är ett enda blad från början

Vad garanterar fixen fortfarande inte?

Utplattningen är enkelriktad och litar på den ordning den finner. EnumNumTree samlar in par i filordning, och GetPageLabel tillämpar sista intervallet vars nyckel är mindre än eller lika med sidindexet, så en främmande fil vars blad är i fel ordning, vilket §7.9.7 förbjuder men som ändå cirkulerar, kan fortfarande ge fel etiketter tills du bygger om intervallen med ClearPageLabels och färska AddPageLabels-anrop. Etiketter binds också till sidindex, inte sidobjekt, så varje operation som ändrar sidantal eller ordning lämnar intervallen där de var. Ett byte på plats som att ersätta sidor med bevarade objektnummer behåller antalet och därmed etiketterna i linje, medan en sammanslagning som att sortera ihopflätade duplexskanningar ger en ny sidsekvens som förtjänar ett nyskrivet set av intervall

Sidetikettansropen, numerträdshanteringen och dokumentdataexporten och -importen som beskrivs här kommer alla i PDF Library for Delphi för Delphi, C++Builder och Lazarus, med referensposten för AddPageLabels som dokumenterar stilvärdena och returkoderna