Teknisk artikkel

Kopiere PDF-objekter mellom dokumenter i Delphi uten krasj

Fletter du to PDF-er for hånd og flytter ett enkelt sideobjekt inn i måldokumentet, går kopieringen rett på en tilgangsbrudd. PDFlibPas ordner dette i CopyForeignObject: den dypkopierer ett indirekte objekt pluss hele referanselukningen, og løser sirkulære tilbake-referanser som /Parent til null i stedet for å rekursere

Hvorfor krasjer det å kopiere én side mellom dokumenter?

Fordi et PDF-sidetre bare er et tre hvis du leser det nedover. Går du gjennom det slik en rekursiv kopierer gjør, og følger hver verdi i hver ordbok, gir sideordboken deg /Parent, som peker tilbake på /Pages-noden du kom fra, og den noden gir deg /Kids, som peker tilbake på siden. ISO 32000-1 §7.7.3 gjør /Parent obligatorisk på alle sidetrenoder unntatt roten, så dette er ikke en misdannet fil du kan avvise — det er den normale formen på hvert dokument du noensinne får utlevert

Den andre halvdelen av problemet er nummereringen. Indirekte objekter identifiseres av et objektnummer som er lokalt for én fil (ISO 32000-1 §7.3.10), så et objekt dratt fra dokument A inn i dokument B må nummereres om, og hver referanse til det inne i den kopierte lukningen må nummereres om på samme måte, ellers peker to referanser som pleide å peke på én delt skrifttype nå på to urelaterte ting. Den omnummereringen er samme jobb en rask fletting gjør på bytenivå, og det er verdt å lese de to side om side: byte-nivå referanseforskyvning for rask PDF-fletting løser det ved å oversette hele filer, mens en kopiering på objektnivå må løse det én kant om gangen

Hvorfor en PDF-kopi på tvers av dokumenter i Delphi krever omtanke: sideordboken og dens /Pages-node lukker en sykel gjennom /Parent og /Kids, en fontlukning går nedover og terminerer, og PDFlibPas nummererer om alle fillokale objektnummere
Sidetreet lukker en løkke gjennom /Parent og /Kids mens innholdslukninger terminerer, og hvert kopierte objektnummer må omkartlegges på veien

Hva PDFlibPas CopyForeignObject faktisk kopierer

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) kloner ett indirekte objekt og alt som kan nås fra det — nestede ordbøker, arrayer, strenger, navn, tall og streams med ordbøkene intakte — inn i det gjeldende valgte dokumentet, og returnerer et ikke-null håndtak til den nye indirekte referansen. Kildeobjektnumre kartlegges om gjennom et levende kart holdt i varigheten av kallet, så et objekt nådd to ganger i lukningen klones én gang og deles to ganger. Den returnerer null, uten å kaste unntak, når kilde-dokument-ID er ukjent, når kilden er det valgte dokumentet selv, eller når ObjectNumber er 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 returnerer 1 ved suksess
    SourceDoc := Lib.SelectedDocument;   // lastingen valgte det den lastet
    Lib.SelectDocument(TargetDoc);       // kopieringen skriver til det valgte dokumentet
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

To detaljer biter folk på første kjøring. LoadFromFile svarer 1 eller 0, ikke en dokument-ID, så håndtaket du trenger kommer fra SelectedDocument rett etter lastingen; og kopieringen skriver alltid inn i det SelectDocument sist gjorde gjeldende, aldri inn i dokumentet du lastet fra. Internt bærer rekursjonen også en hard dybdegrense på 64, som er et sikkerhetsnett mot patologisk nesting, ikke mekanismen som håndterer sykler — sykkelhåndteringen er separat og bevisst

Hvorfor bryter ikke reservering av en Nil-mapping sykelen?

Fordi Nil i mapping-tabellen betyr to forskjellige ting samtidig, og koden kan ikke skille dem. Det opplagte forsvaret mot en sykel er å legge til kartoppføringen før rekursjonen inn i objektet, slik at alt som looper tilbake finner oppføringen og stopper. Men oppføringen kan ikke holde det ekte målet ennå — målet finnes ikke før lukningen under det er skrevet — så den holder Nil, og oppslaget som skulle fange tilbakekanten leser Nil og konkluderer at objektet aldri ble kartlagt

// Ødelagt: et reservert Nil-mål er umulig å skille fra «ikke kartlagt ennå»
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;          // reservert, fortsatt Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // fylles tilbake først på veien ut
end;

Følg det gjennom sideløkken. Klone av siden når /Parent, rekursere inn i /Pages-noden, som når /Kids, som rekursere tilbake inn i siden — hvis reserverte oppføring fremdeles leser Nil, så den klones en gang til, og en tredje, hvert nivå skyver en ny ramme og et nytt halvbygd objekt. Det du observerer er heller ikke en ren stack overflow: de ytre rammene sitter på referanser hvis mål aldri ble tildelt, så første skriving gjennom én av de plassene er en tilgangsbrudd et sted som ikke ligner i det hele tatt på sidekopieringen som forårsaket det

Hvorfor reservering av et Nil-kartmål ikke stopper sykelen i en PDFlibPas-kopi over dokumenter: oppslaget kan ikke skille en reservert oppføring fra en ukartlagt, så kopiereren synker gjennom stadig dypere halvbygde rammer til en skriving krasjer
Fordi et Nil-mål svarer to forskjellige spørsmål samtidig, blir tilbakekanten aldri gjenkjent og siden klones på nytt ved hver gjennomgang

Fiksen: en eksplisitt in-progress-tilstand

Reparasjonen er å slutte å overbelaste Nil og spørre direkte. En kartoppføring hvis mål fortsatt er utildelt betyr dette objektet er i ferd med å klones, og et InProgress-predikat tester akkurat det før det vanlige oppslaget kjører. Når det er sant, er kanten en sykel tilbake inn i en forfader av den gjeldende klonen, og PDFlibPas sender ut et null-objekt for den i stedet for å følge den

// En kartoppføring med Nil-mål markerer en pågående klone
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;

// ... inne i CloneObject, for en indirekte referanse:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // sirkulær tilbakekant, ikke rekursér
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);            // hengende kilde-referanse
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reserver, rekursér deretter
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // fyll tilbake
end;
Exit(NewRef);

Dette er trygt å generalisere bare på grunn av en strukturell egenskap ved PDF: sykler i objektgrafen viser seg på tilbake-lenker, ikke på innholdskantene. /Parent i sidetreet og /Prev i en disposisjonskjede peker oppover eller bakover mot noe allerede besøkt; lukningen av en font, et bilde-XObject eller et skjema-XObject går nedover og terminerer. Så en kopi av en fontdeskriptor, et fargerom eller en skyggeleggingsordbok rammes ikke av null-erstattningen — ingenting i de lukningene treffer noen gang InProgress. Kostnaden, sagt rent ut, er at den sirkulære kanten ikke overlever kopieringen. En sideordbok klonet på denne måten ankommer med /Parent som et null-objekt, som ISO 32000-1 §7.3.9 gjør ekvivalent med en fraværende oppføring, så den kopierte siden er et gyldig objekt som ikke tilhører noe sidetre før du lenker det inn i mål-/Pages-noden og retter /Count selv. Et kopiert disposisjonselement mister /Prev på samme måte og trenger søskenkjeden gjenoppbygd. Det er den ærlige avveiningen: CopyForeignObject gir deg en korrekt lukning og lar strukturell omparentering ligge til kalleren, samme grense som utskifting av sider med bevarte objektnummere arbeider innenfor

Fiksen i PDFlibPas CopyForeignObject for Delphi: en eksplisitt InProgress-test kjøres før kartoppslaget, en sirkulær tilbakekant blir et null-objekt, og kalleren lenker den kopierte siden inn i målsidetreet etterpå
En eksplisitt in-progress-tilstand erstatter den overbelastede Nil, så tilbakekanten løses til null og kalleren står igjen med én strukturell reparasjon å gjøre

Hvorfor kartoppføringen må reserveres før NewObjRef

Et opplagt alternativ ville omgå hele in-progress-føringen: allokér først et tomt skall-objekt, registrer dets ekte nummer i kartet, og fyll deretter skallet når barna er klonet. Det fungerer ikke her, fordi TPDFIndObj.Obj er skrivebeskyttet og innholdet ikke kan erstattes etter konstruksjon — det finnes intet skall å fylle. Nummeret og innholdet bestemmes sammen av NewObjRef, noe som betyr at kartoppføringen må opprettes før det rekursive kallet og fullføres etter det, og intervallet mellom de to øyeblikkene er presis det InProgress må dekke. En konsekvens verdt å vite før du diff-er utdata: fordi NewObjRef kjører etter at barnelukningen er skrevet, blir nummereringen i målet bunn-opp, og objektnummere vil ikke speile kilde-rekkefølgen. Ingenting i filformatet bryr seg, men en byte-sammenligning mot et håndbygd forventningsbilde gjør det. Hvis en kjøring etterlater objekter du bestemte deg for ikke å lenke til noe, er de urefererte snarere enn korrupte, og mark-and-sweep-innsamling av utilgjengelige PDF-objekter er verktøyet som rydder dem ut før lagring

Regresjonstesten som dekker dette trenger én detalj som overrasker folk som skriver tester mot TPDFlib: konstruktøren holder allerede et standarddokument, så DocumentCount starter på 1 og en to-dokuments fixture må asserte >= 2, ikke = 2. Ved siden av den vellykkede kopieringen fester testen de tre avvisningene — en ukjent kilde-ID, det valgte dokumentet som sin egen kilde, og et objektnummer på null — alle returnerer 0 i stedet for å kaste, fordi en fletteløkke er et dårlig sted å oppdage at en vokterklausul kaster

Hvor dette passer inn i en flettepipeline

Kopiering på objektnivå er primitivet du griper etter når fletting av hele filer er for grovkornet: å løfte ett fontprogram ut av en mal, trekke ett enkelt skjema-XObject inn i et stemplingsdokument, eller flytte en annotering med sine appearance-streams på tvers av filer uten å dra med resten av siden. PDFlibPas eksponerer det som ett kall mot lastede dokumenter, og du kan se hvordan det ligger i forhold til resten av objekt-API-et på lavt nivå i PDFlibPas Delphi PDF Library-referansen