I PDFlibPas, Delphi PDF-biblioteket, fick en sida som flyttats med MovePage förr exakt samma MediaBox-, CropBox- och Resources-objekt som dess gamla Pages-nod höll, så ett senare SetPageBox eller DrawText på den flyttade sidan skrev tyst om den noden och vart syskon som fortfarande ärvde från den. Sedan v3.539.36 får den flyttade sidan sina egna kopior, och en indirekt referens förblir en referens. Samma release stänger två relaterade vägar: SetPageBox på en indirekt box som flera sidor delar, och CopyPageRanges som lämnade sidor i källdokumentet knutna till sin Pages-nod, med CropBox knuten till MediaBox
Rapporterna som leder hit nämner aldrig objektidentitet. De säger saker som "Jag beskar sida 7 och sidorna 8 till 12 beskars också", eller "Jag smalnade av CropBox och MediaBox flyttade med", eller, den mest förvirrande, "Jag kopierade en sida till ett nytt dokument och originalfilen ändrades". Inget kraschar, inget läcker, och den sparade filen är en helt giltig PDF. Den innehåller bara geometri ingen bett om
Varför storleksänder SetPageBox på en sida dess syskon?
SetPageBox storleksände syskon för att två sidträdsposter pekade på en och samma array i minnet, och SetPageBox redigerar sin målarray på plats. Varje sida eller Pages-nod som höll samma instans såg redigeringen. Tre kodvägar i PDFlibPas framställde den delningen före v3.539.36:
MovePagematerialiserar de ärvbara attributen på sidan innan den kopplas loss från sin förälder, och den fäste anfäderns egna objekt i stället för kopior, så den flyttade sidan och dess tidigare syskon delade en boxarray och en Resources-ordbokSetPageBoxföljde indirekta referenser och redigerade den refererade arrayen, så en fil där flera sidor pekar på ett/MediaBox 11 0 R-objekt fick alla de sidorna storleksändrade av ett anrop, oavsett omMovePagevar inblandat eller inteCopyPageRangesmaterialiserar ärvda värden på källsidan innan den klonas in i måldokumentet, och den fäste Pages-nodinstanserna på källsidan, plus MediaBox-instansen själv som standard-CropBox
Fallet MovePage har en kort historia. Före v3.539.27 förde MovePage bara med sig /Resources, så en sida flyttad under en annan förälder tog tyst den förälderns storlek och rotation. v3.539.27 fixade de saknade MediaBox, CropBox och Rotate, vilket också är vad CollateDocumentsEx förlitar sig på när den ordnar om sidor, men den fäste anfäderns värden som delade instanser. Det är fönstret v3.539.36 stänger. Vägarna SetPageBox och CopyPageRanges är äldre; alla byggen före v3.539.36 har dem
Direkta värden, indirekta referenser och ärftning av sidattribut
En korrekt kopia av ett ärvt sidattribut duplicerar direkta värden och behåller indirekta referenser som referenser, för det är distinktionen ISO 32000-1 själv drar. Ett direkt objekt som [0 0 400 300] skrivet inuti en ordbok tillhör enbart den ordboken. Ett indirekt objekt, definierat en gång som 11 0 obj och citerat som 11 0 R, delas med flit: ISO 32000-1 §7.3.10 gör det adresserbart från var som helst i filen, och vart 11 0 R betyder samma objekt
Ärftning av sidattribut, ISO 32000-1 §7.7.3.4, lägger till ett tredje fall. Resources, MediaBox, CropBox och Rotate kan sitta på en Pages-nod och gälla varje avkommande sida som inte definierar egna. Sidan håller inte värdet; den slår upp värdet via /Parent. Den uppslagskedjan bryts i ögonblick en sida byter förälder, vilket är varför MovePage och BalancePageTree först måste skriva de effektiva värdena på sidan själv. Frågan är bara hur de ska skrivas
Varför en objektpool döljer misstaget
I PDFlibPas ägs vart tolkat eller skapat PDF-objekt av dokumentets TPDFStructure-pool, och ordböcker och arrayer lagrar bara pekare till sina poster. TPDFDictionary.Add registrerar pekaren och inget annat. Att lägga en instans i två förälderbehållare är därför lagligt på varje nivå runtime kan kontrollera: ingen double free vid nedmontering, ingen referensräknare som kan gå fel, ingen exception. Serialisering är lika förlåtande, eftersom varje behållare skriver den delade instansens aktuella värde inline, och före någon redigering är utdatat byte för byte vad en korrekt kopia skulle framställa
Aliasingen syns först när någon muterar den delade instansen på plats. SetPageBox gör exakt det genom ett rektangelomslag över den befintliga arrayen, och att rita på en sida gör det mot Resources-ordboken när en font eller bild registreras. Redigeringen landar, tyst, i vart annat objekt som håller pekaren
Hur PDFlibPas v3.539.36 kopierar i stället för att dela
PDFlibPas v3.539.36 fixar problemet i båda ändar: materialisering fäster nu kopior, och boxskrivningar redigerar nu bara en array sidan äger. Varje fix täcker ett fall den andra inte kan
Materialiseringshjälparen, PLInheritPageAttributes, fäster nu Page.Owner.Decode(Value.Output) i stället för Value. Att köra tur och retur genom serialiseraren är ett trubbigt men exakt sätt att få PDF-semantik gratis. En direkt array eller ordbok serialiseras till sin bokstavliga text och avkodas till en färsk, oberoende instans. En indirekt referens serialiseras till 11 0 R och avkodas till ett nytt referensobjekt som pekar på samma objekt 11, så sidan hänvisar fortfarande till det delade objektet i stället för att få en inlined kopia, vilket bevarar referensbeteendet introducerat i v3.539.27. Kopian är exakt så djup som den direkta strukturen: allt som nås via en referens inuti en kopierad ordbok förblir delat, som filformatet avser. BalancePageTree anropar samma hjälpare för varje sida den ger ny förälder, så sidor materialiserade där får också separata instanser
Att kopiera ensamt räcker inte, för referensfallet pekar fortfarande på ett delat objekt. Följde SetPageBox den referensen och redigerade objekt 11 skulle den flyttade sidan åter storleksändra den gamla föräldern och dess andra barn. Därför tillämpar boxskrivaren nu copy-on-write: den redigerar på plats bara när sidans egen post är en direkt array, och ersätter en indirekt eller saknad box med en ny direkt array. Objekt 11 lämnas orört för varje annan sida som citerar det
| Kodväg | Före v3.539.36 | Sedan v3.539.36 |
|---|---|---|
MovePage-materialisering | Sidan håller anfäderns egna direkta instanser | Sidan håller avkodade kopior; referenser förblir referenser |
SetPageBox | Följer en referens och redigerar den delade arrayen | Redigerar bara en direkt array på sidan, annars skriver en ny |
CopyPageRanges-källsida | Delar Pages-nodboxar; CropBox är MediaBox-instansen | Varje materialiserat värde på källsidan är en kopia |
| Standardboxar vid kloning av sidresurser | CropBox, BleedBox, TrimBox och ArtBox delar en array | Varje standardbox får sin egen array |
Sista raden är den latenta. När biblioteket klonar en sidas resurser för sidfångst eller sammanslagning fyller den i saknade CropBox-, BleedBox-, TrimBox- och ArtBox-poster, och de brukade vara samma arrayinstans. Ingen nuvarande anropare lät aliase överleva länge nog att redigeras, men nästa anropare skulle ha gjort det. Hur de standardboxvärdena väljs är ett eget ämne, taget upp i PDFlibPas-guiden till TrimBox-, BleedBox- och CropBox-standarder
Återskapa MovePage-aliasingen med en handbyggd PDF
Det snabbaste sättet att kontrollera vilket PDFlibPas-bygge som helst är en liten handskriven PDF laddad med LoadFromString, där vart objektnummer är känt på förhand. Hjälparen nedan skriver en klassisk xref-tabell med korrekt beräknade byteoffset, så testet inte förlitar sig på tolkarens återställningsbeteende för skadade filer
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // 0-baserad byteoffset för "N 0 obj"
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // varje post är exakt 20 byte
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
Testdokumentet har två mellanliggande Pages-noder. Nod 3 bär en indirekt MediaBox (objekt 11, 400 gånger 300 punkter), en direkt CropBox och en direkt Resources-ordbok, och äger två sidor. Nod 4 har en MediaBox i Letter-storlek och äger tredje sidan. Att flytta sida 1 till position 3 ger den ny förälder under nod 4, vilket är exakt den flytt som behöver materialisering: utan den skulle sidan bli en Letter-sida
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // sidan vi precis flyttade
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// Granska den gamla föräldern FÖRE att en annan sida väljs (se nedan)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // tidigare sida 2, fortfarande under nod 3
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension) tar boxtyp 1 för MediaBox och 2 för CropBox, och dimension 2 för bredd. Med standardursprunget nere till vänster betyder SetPageBox(1, 0, 200, 200, 200) vänster 0, topp 200, 200 bred och 200 hög. På byggen mellan v3.539.27 och v3.539.35 misslyckas syskonkontrollerna: CropBox-redigeringen landar i nod 3:s direkta array, och MediaBox-redigeringen skriver om objekt 11 via referensen
Ändrar CopyPageRanges källdokumentet?
Sedan v3.539.36 skriver CopyPageRanges fortfarande på källsidorna, men varje värde den skriver är en separat kopia, så senare redigeringar på källan stannar lokala på sidan du redigerar. Själva skrivandet är med flit: källsidan behöver explicit MediaBox, CropBox, Rotate och Resources innan dess ordbok klonas in i målet, annars skulle kopian förlora allt den ärvt. Omnumrering och kopiering av sidan in i målet tas upp i korsdokumenterad djupkopiering av objekt i PDFlibPas; denna bugg satt på källsidan, som de flesta antar att en kopia bara läser
Utdatat visade det aldrig. Delat eller kopierat serialiseras de materialiserade värdena identiskt, så båda dokumenten sparades byte för byte likadant före och efter fixen. Först en redigering av källdokumentet efter kopian avslöjade aliase:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // blir det valda dokumentet
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // smalna bara av CropBox
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // kopian behåller sin ursprungsstorlek
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Före v3.539.36 ärvde båda sidorna här rotnodens direkta MediaBox, kopian fäste den instansen på källsida 1, och fäste den igen som sida 1:s CropBox. Att smalna av CropBox smalnade alltså av MediaBox, och att storleksändra MediaBox storleksände sida 2 via rotnoden. Arbetsflöden som kopierar ut sidor och sedan fortsätter redigera källan, som att sammanfoga duplex-skanningar till en PDF innan originalen trimmas, är där detta dök upp
Varför är instansaliasing så svår att testa?
Instansaliasing är svår att testa för att den observerbara effekten behöver tre steg i en specifik ordning: skapa aliase, mutera ena sidan, granska sedan andra sidan innan något annat rör den. De flesta tester gör bara första steget och jämför sparad utdata, som är identisk oavsett om aliase finns eller inte
Ordningsträcket i PDFlibPas är SelectPage. Att välja en sida återanvänder aktuell font via SelectFont, som registrerar den fonten i sidans resurser. En sida utan egen /Resources löser upp sig till sin förälders ordbok, så att bara välja en sådan sida lägger legitimt till /Font på Pages-noden. I MovePage-testet ovan lägger val av tidigare sida 2 till Helvetica-posten på nod 3, vilket är korrekt beteende och ingen läcka. Det är därför kontrollen GetObjectToString(3) körs före SelectPage(1); byt ordning på dem och testet faller på ett fixat bygge
Den regeln markerar också vad v3.539.36 med flit lämnar orört. Att skriva en resurs till en sida som ärver sin Resources-ordbok skriver in i anfäderns ordbok, och varje syskon ser den nya posten. Det är ärftning som fungerar som specificerat, inte instansdelning, och det är ofarligt för att lägga till ett font- eller bildnamn i en delad ordbok inte ändrar hur andra sidor renderar. Behöver du att en sida sluta ärva, ge den sin egen Resources-ordbok först
Checklista för PDF-objektmodellkod
Lärdomarna generaliserar till vilken PDF-objektmodell som helst byggd på en pool och pekarebehållare, i Delphi eller någon annanstans:
- Vid materialisering av ärvda attribut enligt ISO 32000-1 §7.7.3.4, djupkopiera direkta värden och behåll indirekta referenser som nya referenser till samma objekt
- Anropa aldrig
Addmed en existerande instans i en andra behållare om inte delningen är avsedd och dokumenterad; ägande av en pool betyder att runtime aldrig kommer klaga - Redigera på plats bara vad aktuell nod äger som direkt objekt; ersätt indirekta eller ärvda värden med ett färskt direkt objekt (copy-on-write)
- Standardvärden härledda från en annan post, som en CropBox från en MediaBox, behöver sin egen instans
- Testa aliasing med mutera-sedan-granska-sekvenser på den andra innehavaren, och kontrollera ordningen av anrop som kan skriva legitimt emellan
- Att jämföra sparad utdata bevisar ingenting här: delade och kopierade värden serialiseras identiskt tills första redigeringen
- På PDFlibPas, uppgradera till v3.539.36 eller senare om du anropar
MovePage,CollateDocumentsEx,BalancePageTreeellerCopyPageRangesoch sedan redigerar sidboxar eller ritar på sidor
PDFlibPas exponerar sidträdredigering, korsdokumentkopiering och sidboxkontroll genom en enda TPDFlib-klass för Delphi, C++Builder och Free Pascal. Se produktsidan för PDFlibPas Delphi PDF library för utgåvor, plattformar och den fullständiga API-referensen