Teknisk artikel

PDF-objektkopiering mellan dokument i Delphi: cykelkrascher

Slå ihop två PDF:er för hand, flytta ett enskilt sidobjekt till måldokumentet, och kopieringen går rakt in i ett åtkomstfel. PDFlibPas rättar detta i CopyForeignObject: den djupkopierar ett indirekt objekt plus hela dess referensclosure och löser cykliska bakåtreferenser som /Parent till null i stället för att rekursera

Varför kraschar kopiering av en sida mellan dokument?

Eftersom ett PDF-sidträd bara är ett träd om du läser det nedåt. Gå igenom det som en rekursiv kopierare gör, följ varje värde i varje ordbok, och sidordboken räcker dig /Parent, som pekar tillbaka på /Pages-noden du kom från, och den noden räcker dig /Kids, som pekar tillbaka på sidan. ISO 32000-1 §7.7.3 gör /Parent obligatorisk på varje sidträdsnod utom roten, så detta är inte en felformad fil du kan avvisa — det är den normala formen av varje dokument du någonsin får

Den andra halvan av problemet är numrering. Indirekta objekt identifieras av ett objektnummer som är lokalt för en fil (ISO 32000-1 §7.3.10), så att ett objekt som dras från dokument A till dokument B måste omnumreras, och varje referens till det inuti den kopierade closuren måste omnumreras på samma sätt, annars pekar två referenser som tidigare pekade på ett delat typsnitt nu på två orelaterade saker. Den omnumringen är samma jobb en snabb sammanslagning gör på bytenivå, och det är värt att läsa de två sida vid sida: referensförskjutning på bytenivå för snabb PDF-sammanslagning löser det genom att översätta hela filer, medan en objektnivåkopia måste lösa det en kant i taget

Varför en PDF-kopia mellan dokument i Delphi behöver omsorg: sidordboken och dess /Pages-nod stänger en cykel genom /Parent och /Kids, en typsnittsclosure går nedåt och terminerar, och PDFlibPas mappar om vart och ett av filens lokala objektnummer
Sidträdet stänger en loop genom /Parent och /Kids medan innehållsclosures terminerar, och vart och ett av de kopierade objektnumren måste mappas om på vägen

Vad PDFlibPas CopyForeignObject faktiskt kopierar

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonar ett indirekt objekt och allt som kan nås från det — nästlade ordböcker, arrayer, strängar, namn, tal och strömmar med sina ordböcker intakta — in i det för tillfället valda dokumentet, och returnerar ett icke-noll handtag till den nya indirekta referensen. Källans objektnummer mappas om genom en levande karta som hålls under anropets varaktighet, så att ett objekt som nås två gånger i closuren klonas en gång och delas två gånger. Den returnerar noll, utan att lyfta undantag, när källdokumentets ID är okänt, när källan är det valda dokumentet själv, eller när ObjectNumber är under 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile returnerar 1 vid framgång
    SourceDoc := Lib.SelectedDocument;   // laddningen valde det den laddade
    Lib.SelectDocument(TargetDoc);       // kopieringen riktar in på det valda dokumentet
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Två detaljer biter folk på första körningen. LoadFromFile svarar 1 eller 0, inte ett dokument-ID, så handtaget du behöver kommer från SelectedDocument direkt efter laddningen; och kopieringen skriver alltid in i vad SelectDocument senast gjorde aktuellt, aldrig in i dokumentet du laddade från. Internt bär rekursionen också en hård djuptak på 64, som är ett säkerhetsnät mot patologisk nästling, inte mekanismen som hanterar cykler — cykelhanteringen är separat och medveten

Varför bryter reservering av en Nil-mappning inte cykeln?

Eftersom Nil i mappningstabellen betyder två olika saker på en gång, och koden inte kan skilja dem åt. Det uppenbara försvaret mot en cykel är att lägga till map-posten innan man rekursar in i objektet, så att allt som loopar tillbaka hittar posten och stannar. Men posten kan ännu inte hålla det riktiga målet — målet existerar inte förrän closuren under den har skrivits — så den håller Nil, och uppslagningen som ska fånga tillbakakanten läser Nil och drar slutsatsen att objektet aldrig mappades

// Fel: ett reserverat Nil-mål är oskiljbart från "inte mappat ännu"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reserverad, fortfarande Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // backfillas först på vägen ut
end;

Följ det genom sidloopar. Klonen av sidan når /Parent, rekursar in i /Pages-noden, som når /Kids, som rekursar tillbaka in i sidan — vars reserverade post fortfarande läser Nil, så den klonas en andra gång, och en tredje, varje nivå skjuter på en färsk ram och ett färskt halvfärdigt objekt. Det du observerar är inte heller en ren stack overflow: de yttre ramarna sitter på referenser vars mål aldrig tilldelades, så det första skrivandet genom en av dessa platser är ett åtkomstfel någonstans som inte liknar sidkopieringen som orsakade det

Varför reservering av ett Nil-kartmål inte stoppar cykeln i en PDFlibPas-kopia mellan dokument: uppslagningen kan inte skilja en reserverad post från en omappad, så kopieraren sjunker genom allt djupare halvfärdiga ramar tills ett skrivande kraschar
Eftersom ett Nil-mål svarar på två olika frågor på en gång, känns tillbakakanten aldrig igen och sidan klonas igen vid varje pass

Rättningen: ett uttryckligt InProgress-tillstånd

Reparationen är att sluta överbelasta Nil och ställa frågan direkt. En map-post vars mål fortfarande är otilldelat betyder detta objekt klonas för närvarande, och ett InProgress-predikat testar exakt det innan det vanliga uppslaget körs. När det är sant är kanten en cykel tillbaka till en förfader till den aktuella klonen, och PDFlibPas emitterar ett null-objekt för den i stället för att följa den

// En map-post med ett Nil-mål markerar en pågående klon
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... inuti CloneObject, för en indirekt referens:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // cyklisk tillbakakant, rekursera inte
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // hängande källreferens
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reservera, rekursera sedan
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // backfill
end;
Exit(NewRef);

Detta är säkert att generalisera bara på grund av ett strukturellt faktum om PDF: cykler i objektgrafen förekommer på bakåtlänkar, inte på innehållskanterna. /Parent i sidträdet och /Prev i en bokmärkeskedja pekar uppåt eller bakåt på något redan besökt; closuren av ett typsnitt, en bild-XObject eller en formulär-XObject går nedåt och terminerar. Så en kopia av en typsnittsbeskrivning, en färgrymd eller en skuggordbok påverkas inte av null-ersättningen — ingenting i de closurena träffar någonsin InProgress. Kostnaden, sagt rakt ut, är att den cykliska kanten inte överlever kopieringen. En sidordbok som klonas så här kommer med /Parent som null-objekt, vilken ISO 32000-1 §7.3.9 gör likvärdig med en frånvarande post, så att den kopierade sidan är ett giltigt objekt som tillhör inget sidträd förrän du länkar den in i mål-/Pages-noden och rättar /Count själv. Ett kopierat bokmärkesobjekt förlorar sin /Prev på samma sätt och behöver syskonkedjan byggd om. Det är den ärliga avvägningen: CopyForeignObject ger dig en korrekt closure och lämnar den strukturella omkopplingen till anroparen, vilket är samma gräns utbyte av sidor med bevarade objektnummer arbetar inom

Rättningen i PDFlibPas CopyForeignObject för Delphi: ett uttryckligt InProgress-test körs före map-uppslaget, en cyklisk tillbakakant blir ett null-objekt, och anroparen länkar om den kopierade sidan in i målsidträdet efteråt
Ett uttryckligt pågående-tillstånd ersätter den överbelastade Nilen, så att tillbakakanten löses till null och anroparen lämnas med en strukturell reparation att göra

Varför map-posten måste reserveras före NewObjRef

Ett uppenbart alternativ skulle kringgå hela den pågående-dansen: allokera först ett tomt skalobjekt, registrera dess riktiga nummer i mapen, och fyll sedan skalet när barnen klonats. Det fungerar inte här, eftersom TPDFIndObj.Obj är skrivskyddad och dess innehåll inte kan ersättas efter konstruktion — det finns inget skal att fylla. Numret och innehållet beslutas tillsammans av NewObjRef, vilket betyder att map-posten måste skapas före det rekursiva anropet och färdigställas efter det, och intervallet mellan de två ögonblicken är precis vad InProgress måste täcka. En konsekvens värd att känna till innan du diffar utdata: eftersom NewObjRef körs efter att barnclosuren skrivits, kommer numreringen i målet ut nedifrån och upp, och objektnumren speglar inte källordningen. Ingenting i filformatet bryr sig, men en bytejämförelse mot en handbyggd förväntan gör det. Om en körning lämnar objekt du beslutat inte länka in i något, är de orefererade snarare än skadade, och mark-and-sweep-insamling av onåbara PDF-objekt är verktyget som städar bort dem innan sparandet

Regressionen som täcker detta behöver en detalj som förvånar folk som skriver tester mot TPDFlib: konstruktorn håller redan ett standarddokument, så DocumentCount börjar på 1 och en tvådokumentfixture måste asserta >= 2, inte = 2. Vid sidan av den lyckade kopieringen fäster testet de tre avvisandena — ett okänt käll-ID, det valda dokumentet som sin egen källa och ett objektnummer på noll — alla returnerar 0 i stället för att lyfta undantag, eftersom en sammanslagningsloop är en dålig plats att upptäcka att en guard-klausul kastar

Var detta passar i en sammanslagningspipeline

Objektnivåkopiering är primitiven du sträcker dig efter när hel filsammanslagning är för grov: att lyfta ut ett typsnittsprogram ur en mall, dra in en enskild formulär-XObject i ett stämpeldokument, eller flytta en annotering med sina utseendeströmmar över filer utan att släpa med resten av sidan. PDFlibPas exponerar den som ett enda anrop mot laddade dokument, och du kan se hur den passar med resten av det lågnivå objekt-API:t i referensen PDFlibPas Delphi PDF Library