In PDFlibPas, de Delphi PDF-library, kreeg een pagina die met MovePage was verplaatst vroeger precies dezelfde MediaBox-, CropBox- en Resources-objecten die zijn oude Pages-node bevatte, dus een latere SetPageBox of DrawText op de verplaatste pagina herschreef stilletjes die node en elke zuster die er nog van erfde. Sinds v3.539.36 krijgt de verplaatste pagina zijn eigen kopieën, en een indirecte verwijzing blijft een verwijzing. Dezelfde release dicht twee verwante paden: SetPageBox op een indirecte box die meerdere pagina's delen, en CopyPageRanges die pagina's van het brondocument aan hun Pages-node liet hangen, met de CropBox vast aan de MediaBox
De meldingen die hierheen leiden spreken nooit over objectidentiteit. Ze zeggen dingen als "ik heb pagina 7 bijgesneden en pagina 8 tot 12 is ook bijgesneden", of "ik heb de CropBox versmald en de MediaBox schoof mee", of, het verwarrendste, "ik heb een pagina naar een nieuw document gekopieerd en het originele bestand is veranderd". Niets crasht, niets lekt, en het opgeslagen bestand is prima geldige PDF. Het bevat alleen geometrie die niemand heeft gevraagd
Waarom schaalt SetPageBox op één pagina de zusterpagina's mee?
SetPageBox schoof zusters mee omdat twee entries in de paginaboom naar één array in het geheugen wezen, en SetPageBox bewerkt zijn doelarray ter plekke. Elke pagina of Pages-node met dezelfde instantie zag de bewerking. Drie codepaden in PDFlibPas veroorzaakten dat delen vóór v3.539.36:
MovePagematerialiseert de overerfbare attributen op de pagina voordat hij hem van zijn parent loskoppelt, en hij hing de eigen objecten van de voorouder vast in plaats van kopieën, dus de verplaatste pagina en zijn voormalige zusters deelden een boxarray en een Resources-dictionarySetPageBoxvolgde indirecte verwijzingen en bewerkte de gerefereerde array, dus in een bestand waarin meerdere pagina's naar één/MediaBox 11 0 R-object wijzen werden al die pagina's door één aanroep geschaald, ofMovePageer nu bij betrokken was of nietCopyPageRangesmaterialiseert overgeërfde waarden op de bronpagina voordat hij haar kloont naar het doeldocument, en hij hing de Pages-node-instanties aan de bronpagina, plus de MediaBox-instantie zelf als default CropBox
Het MovePage-geval heeft een korte voorgeschiedenis. Vóór v3.539.27 nam MovePage alleen /Resources mee, dus een pagina die onder een andere parent was verplaatst nam stilletjes de grootte en rotatie van die parent aan. v3.539.27 loste de ontbrekende MediaBox, CropBox en Rotate op, waarop ook CollateDocumentsEx vertrouwt bij het herordenen van pagina's, maar hij hing de waarden van de voorouder vast als gedeelde instanties. Dat is het venster dat v3.539.36 sluit. De SetPageBox- en CopyPageRanges-paden zijn ouder; elke build vóór v3.539.36 heeft ze
Directe waarden, indirecte verwijzingen en pagina-attribuuterfenis
Een correcte kopie van een overgeërfd pagina-attribuut verdubbelt directe waarden en houdt indirecte verwijzingen als verwijzingen, want dat is precies het onderscheid dat ISO 32000-1 zelf trekt. Een direct object zoals [0 0 400 300] binnen een dictionary hoort aan die dictionary alleen. Een indirect object, één keer gedefinieerd als 11 0 obj en aangehaald als 11 0 R, is bij ontwerp gedeeld: ISO 32000-1 §7.3.10 maakt het adresseerbaar vanuit elke hoek van het bestand, en elke 11 0 R betekent hetzelfde object
Pagina-attribuuterfenis, ISO 32000-1 §7.7.3.4, voegt een derde geval toe. Resources, MediaBox, CropBox en Rotate mogen op een Pages-node zitten en gelden voor elke afstammende pagina die geen eigen definitie heeft. De pagina bewaart de waarde niet; hij zoekt de waarde op via /Parent. Die opzoekketen breekt op het moment dat een pagina van parent wisselt, en daarom moeten MovePage en BalancePageTree eerst de effectieve waarden op de pagina zelf wegschrijven. De vraag is alleen hoe ze geschreven worden
Waarom een objectpool de fout verhult
In PDFlibPas is elk geparsed of aangemaakt PDF-object eigendom van de TPDFStructure-pool van het document, en dictionaries en arrays bewaren kale pointers naar hun entries. TPDFDictionary.Add registreert de pointer en niets anders. Eén instantie aan twee parentcontainers toevoegen is daarom legaal op elk niveau dat de runtime kan controleren: geen double free bij het afbreken, geen referentieteller die kan ontsporen, geen exception. Serialisatie is net zo vergevingsgezind, want elke container schrijft de huidige waarde van de gedeelde instantie inline, en vóór elke bewerking is de uitvoer byte voor byte wat een correcte kopie zou opleveren
De aliasing komt pas boven water zodra iemand de gedeelde instantie ter plekke muteert. SetPageBox doet precies dat via een rectangle-wrapper om de bestaande array, en tekenen op een pagina doet hetzelfde met de Resources-dictionary zodra een font of afbeelding wordt geregistreerd. De bewerking landt, stilletjes, in elke andere container die de pointer vasthoudt
Hoe PDFlibPas v3.539.36 kopieert in plaats van deelt
PDFlibPas v3.539.36 pakt het probleem aan beide kanten aan: materialisatie hangt nu kopieën vast, en box-schrijfbewerkingen editen nu alleen een array waarvan de pagina eigenaar is. Elke fix dekt een geval die de ander niet dekt
De materialisatiehelper PLInheritPageAttributes hangt nu Page.Owner.Decode(Value.Output) vast in plaats van Value. Een rondje door de serialisator is een botte maar exacte manier om de PDF-semantiek gratis mee te krijgen. Een directe array of dictionary serialiseert naar zijn letterlijke tekst en decodeert naar een verse, onafhankelijke instantie. Een indirecte verwijzing serialiseert naar 11 0 R en decodeert naar een nieuw referentieobject dat naar hetzelfde object 11 wijst, dus de pagina refereert nog steeds aan het gedeelde object in plaats van een ingelinde kopie te ontvangen, wat het referentiegedrag uit v3.539.27 vasthoudt. De kopie is precies zo diep als de directe structuur: alles wat binnen een gekopieerde dictionary via een verwijzing bereikbaar is, blijft gedeeld, zoals het bestandsformaat bedoeld is. BalancePageTree roept dezelfde helper aan voor elke pagina die hij herparent, dus daar gematerialiseerde pagina's krijgen ook aparte instanties
Alleen kopiëren is niet genoeg, want het referentiegeval wijst nog steeds naar een gedeeld object. Als SetPageBox die verwijzing zou volgen en object 11 zou bewerken, zou de verplaatste pagina de oude parent en diens andere kinderen opnieuw doen meeschalen. De boxschrijver past daarom copy-on-write toe: hij edit ter plekke alleen wanneer de eigen entry van de pagina een directe array is, en vervangt een indirecte of ontbrekende box door een nieuwe directe array. Object 11 blijft onaangeroerd voor elke andere pagina die hem aanhaalt
| Codepad | Vóór v3.539.36 | Sinds v3.539.36 |
|---|---|---|
MovePage-materialisatie | De pagina houdt de eigen directe instanties van de voorouder vast | De pagina houdt gedecodeerde kopieën vast; verwijzingen blijven verwijzingen |
SetPageBox | Volgt een verwijzing en edit de gedeelde array | Edit alleen een directe array op de pagina, schrijft anders een nieuwe |
CopyPageRanges-bronpagina | Deelt Pages-node-boxen; de CropBox is de MediaBox-instantie | Elke gematerialiseerde waarde op de bronpagina is een kopie |
| Default-boxen bij het klonen van paginabronnen | CropBox, BleedBox, TrimBox en ArtBox delen één array | Elke default-box krijgt zijn eigen array |
De laatste rij is de latente. Wanneer de library de bronnen van een pagina kloont voor paginavastlegging of samenvoegen, vult hij ontbrekende CropBox-, BleedBox-, TrimBox- en ArtBox-entries aan, en dat waren vroeger dezelfde arrayinstantie. Geen enkele huidige aanroeper liet die alias lang genoeg leven om bewerkt te worden, maar de volgende zou het gedaan hebben. Hoe die default-boxwaarden worden gekozen is een onderwerp op zich, behandeld in de PDFlibPas-gids over TrimBox-, BleedBox- en CropBox-defaults
De MovePage-aliasing reproduceren met een handgebouwde PDF
De snelste manier om een willekeurige PDFlibPas-build te controleren is een klein handgeschreven PDF'tje geladen met LoadFromString, waarin elk objectnummer vooraf bekend is. De helper hieronder schrijft een klassieke cross-reference-tabel met correct berekende byte-offsets, zodat de test niet leunt op het herstelgedrag van de parser voor beschadigde bestanden
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); // byte-offset vanaf nul van "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 // elke entry is exact 20 bytes
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;
Het testdocument heeft twee tussenliggende Pages-nodes. Node 3 voert een indirecte MediaBox (object 11, 400 bij 300 punten), een directe CropBox en een directe Resources-dictionary, en bezit twee pagina's. Node 4 heeft een Letter-formaat MediaBox en bezit de derde pagina. Pagina 1 naar positie 3 verplaatsen herparent hem onder node 4, en dat is precies de move die materialisatie nodig heeft: zonder die stap zou de pagina een Letter-pagina worden
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); // de pagina die we zojuist verplaatsten
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');
// Bekijk de oude parent VÓÓR het selecteren van een andere pagina (zie hieronder)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // voormalige pagina 2, nog steeds onder node 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) neemt boxtype 1 voor de MediaBox en 2 voor de CropBox, en dimensie 2 voor de breedte. Met de default-origine linksonder betekent SetPageBox(1, 0, 200, 200, 200) links 0, boven 200, 200 breed en 200 hoog. Op builds tussen v3.539.27 en v3.539.35 falen de zustercontroles: de CropBox-bewerking landt in de directe array van node 3, en de MediaBox-bewerking herschrijft object 11 via de verwijzing
Verandert CopyPageRanges het brondocument?
Sinds v3.539.36 schrijft CopyPageRanges nog steeds op de bronpagina's, maar elke waarde die hij schrijft is een aparte kopie, dus latere bewerkingen op de bron blijven lokaal op de pagina die u editeert. Het schrijven zelf is intentioneel: de bronpagina heeft expliciete MediaBox, CropBox, Rotate en Resources nodig voordat zijn dictionary naar het doel wordt gekloond, anders verliest de kopie alles wat hij erfde. Hernummeren en de pagina naar het doel kopiëren wordt behandeld in cross-document object deep copy in PDFlibPas; deze bug zat aan de bronkant, waarvan de meeste mensen aannemen dat een kopie haar alleen leest
De uitvoer liet het nooit zien. Gedeeld of gekopieerd, de gematerialiseerde waarden serialiseren identiek, dus beide documenten werden vóór en na de fix byte voor byte hetzelfde opgeslagen. Pas een bewerking van het brondocument na de kopie legde de alias bloot:
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; // wordt het geselecteerde document
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // versmalt alleen de 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); // de kopie behoudt zijn originele grootte
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Vóór v3.539.36 erfden beide pagina's hier de directe MediaBox van de rootnode, hing de kopie die instantie vast aan bronpagina 1, en hing haar opnieuw vast als de CropBox van pagina 1. De CropBox versmallen betekende dus de MediaBox versmallen, en de MediaBox herschalen herschaalde pagina 2 via de rootnode. Workflows die pagina's eruit kopiëren en daarna de bron blijven bewerken, zoals duplexscans samenvoegen tot één PDF voordat de originelen worden bijgesneden, zijn waar dit naar boven kwam
Waarom is instantie-aliasing zo moeilijk te testen?
Instantie-aliasing is moeilijk te testen omdat het waarneembare effect drie stappen in een specifieke volgorde vergt: de alias aanmaken, de ene kant muteren, en dan de andere kant inspecteren voordat er iets anders aanzit. De meeste tests doen alleen de eerste stap en vergelijken opgeslagen uitvoer, en die is identiek of de alias nu bestaat of niet
De volgordevalkuil in PDFlibPas is SelectPage. Een pagina selecteren past het huidige font opnieuw toe via SelectFont, wat dat font registreert in de resources van de pagina. Een pagina zonder eigen /Resources valt terug op de dictionary van zijn parent, dus alleen al zo'n pagina selecteren voegt op legitieme wijze /Font toe aan de Pages-node. In de MovePage-test hierboven voegt het selecteren van de voormalige pagina 2 de Helvetica-entry toe aan node 3, wat correct gedrag is en geen lek. Daarom draait de GetObjectToString(3)-controle vóór SelectPage(1); verwissel de twee en de test faalt op een opgeloste build
Die regel markeert ook wat v3.539.36 bewust met rust laat. Een resource schrijven naar een pagina die zijn Resources-dictionary erft, schrijft in de dictionary van de voorouder, en elke zuster ziet de nieuwe entry. Dat is overerving zoals gespecificeerd, geen instantiedeling, en het is onschadelijk want een font- of afbeeldingsnaam aan een gedeelde dictionary toevoegen verandert niet hoe andere pagina's renderen. Als een pagina moet stoppen met erven, geef haar dan eerst een eigen Resources-dictionary
Checklist voor PDF-objectmodelcode
De lessen generaliseren naar elk PDF-objectmodel gebouwd op een pool en pointercontainers, in Delphi of elders:
- Materialiseer overgeërfde attributen volgens ISO 32000-1 §7.7.3.4 met een deep copy van directe waarden, en houd indirecte verwijzingen als nieuwe verwijzingen naar hetzelfde object
- Voeg een bestaande instantie nooit met
Addtoe aan een tweede container tenzij het delen bedoeld en gedocumenteerd is; eigendom van een pool betekent dat de runtime nooit klaagt - Edit ter plekke alleen wat de huidige node als direct object bezit; vervang indirecte of overgeërfde waarden door een vers direct object (copy-on-write)
- Default-waarden afgeleid van een andere entry, zoals een CropBox uit een MediaBox, hebben hun eigen instantie nodig
- Test aliasing met mutate-daarna-inspecteer-reeksen op de andere houder, en controleer de volgorde van aanroepen die ertussen terecht mogen schrijven
- Opgeslagen uitvoer vergelijken bewijst hier niets: gedeelde en gekopieerde waarden serialiseren identiek tot de eerste bewerking
- Op PDFlibPas: upgrade naar v3.539.36 of later als u
MovePage,CollateDocumentsEx,BalancePageTreeofCopyPageRangesaanroept en daarna pagina-boxen editeert of op pagina's tekent
PDFlibPas stelt paginaboom-editing, cross-document kopiëren en pagina-boxbediening beschikbaar via één TPDFlib-klasse voor Delphi, C++Builder en Free Pascal. Zie de productpagina van de PDFlibPas Delphi PDF-library voor edities, platforms en de volledige API-referentie