Dans PDFlibPas, la bibliothèque PDF Delphi, une page déplacée avec MovePage recevait autrefois exactement les mêmes objets MediaBox, CropBox et Resources que son ancien nœud Pages, si bien qu’un SetPageBox ou un DrawText ultérieur sur la page déplacée réécrivait en silence ce nœud et chaque page sœur qui en héritait encore. Depuis v3.539.36, la page déplacée reçoit ses propres copies, et une référence indirecte reste une référence. La même version referme deux chemins liés : SetPageBox sur une box indirecte que plusieurs pages partagent, et CopyPageRanges qui laissait les pages du document source attachées à leur nœud Pages, avec le CropBox lié au MediaBox
Les rapports qui mènent ici ne parlent jamais d’identité d’objets. Ils disent des choses comme « j’ai rogné la page 7 et les pages 8 à 12 se sont rognées aussi », ou « j’ai rétréci le CropBox et le MediaBox a bougé avec lui », ou, la plus déroutante, « j’ai copié une page dans un nouveau document et le fichier d’origine a changé ». Rien ne plante, rien ne fuit, et le fichier sauvegardé est un PDF parfaitement valide. Il contient juste une géométrie que personne n’a demandée
Pourquoi un SetPageBox sur une page redimensionne-t-il ses sœurs ?
SetPageBox redimensionnait les sœurs parce que deux entrées de l’arbre de pages pointaient vers un même tableau en mémoire, et SetPageBox édite son tableau cible sur place. Toute page ou nœud Pages détenant la même instance voyait la modification. Trois chemins de code de PDFlibPas produisaient ce partage avant v3.539.36 :
MovePagematérialise les attributs héritables sur la page avant de la détacher de son parent, et il attachait les objets propres à l’ancêtre plutôt que des copies, si bien que la page déplacée et ses anciennes sœurs partageaient un tableau de box et un dictionnaire ResourcesSetPageBoxsuivait les références indirectes et éditait le tableau référencé, donc un fichier où plusieurs pages pointent vers un même objet/MediaBox 11 0 Rvoyait toutes ces pages redimensionnées par un seul appel, queMovePagesoit intervenu ou nonCopyPageRangesmatérialise les valeurs héritées sur la page source avant de la cloner dans le document cible, et il attachait à la page source les instances du nœud Pages, plus l’instance du MediaBox elle-même comme CropBox par défaut
Le cas MovePage a une histoire courte. Avant v3.539.27, MovePage ne transportait que /Resources, si bien qu’une page déplacée sous un autre parent reprenait en silence la taille et la rotation de ce parent. v3.539.27 a corrigé le MediaBox, le CropBox et le Rotate manquants, ce dont CollateDocumentsEx dépend aussi quand il réordonne des pages, mais il attachait les valeurs de l’ancêtre comme instances partagées. C’est la fenêtre que v3.539.36 referme. Les chemins SetPageBox et CopyPageRanges sont plus anciens ; tout build antérieur à v3.539.36 les a
Valeurs directes, références indirectes et héritage des attributs de page
Une copie correcte d’un attribut de page hérité duplique les valeurs directes et garde les références indirectes comme références, parce que c’est la distinction que ISO 32000-1 lui-même trace. Un objet direct tel que [0 0 400 300] écrit dans un dictionnaire n’appartient qu’à ce dictionnaire. Un objet indirect, défini une fois en 11 0 obj et cité en 11 0 R, est partagé par conception : ISO 32000-1 §7.3.10 le rend adressable depuis n’importe où dans le fichier, et chaque 11 0 R désigne le même objet
L’héritage des attributs de page, ISO 32000-1 §7.7.3.4, ajoute un troisième cas. Resources, MediaBox, CropBox et Rotate peuvent siéger sur un nœud Pages et s’appliquer à toute page descendante qui ne définit pas les siens. La page ne détient pas la valeur ; elle la cherche à travers /Parent. Cette chaîne de recherche casse dès qu’une page change de parent, c’est pourquoi MovePage et BalancePageTree doivent d’abord écrire les valeurs effectives sur la page elle-même. La seule question est de savoir comment les écrire
Pourquoi un pool d’objets cache l’erreur
Dans PDFlibPas, chaque objet PDF analysé ou créé est possédé par le pool TPDFStructure du document, et les dictionnaires et tableaux stockent de simples pointeurs vers leurs entrées. TPDFDictionary.Add enregistre le pointeur et rien d’autre. Ajouter une instance à deux conteneurs parents est donc légal à chaque niveau que le runtime peut vérifier : pas de double libération au démontage, pas de comptage de références qui puisse dérailler, pas d’exception. La sérialisation est tout aussi conciliante, puisque chaque conteneur écrit en ligne la valeur courante de l’instance partagée, et avant toute modification la sortie est octet pour octet ce qu’une copie correcte produirait
L’aliasing ne se manifeste que quand quelqu’un mute l’instance partagée sur place. SetPageBox fait exactement ça par un wrapper rectangle au-dessus du tableau existant, et dessiner sur une page le fait au dictionnaire Resources quand une police ou une image est enregistrée. La modification atterrit, en silence, dans tous les autres conteneurs qui détiennent le pointeur
Comment PDFlibPas v3.539.36 copie au lieu de partager
PDFlibPas v3.539.36 corrige le problème aux deux bouts : la matérialisation attache désormais des copies, et l’écriture de box n’édite plus qu’un tableau possédé par la page. Chaque correction couvre un cas que l’autre ne peut pas
Le helper de matérialisation, PLInheritPageAttributes, attache désormais Page.Owner.Decode(Value.Output) au lieu de Value. Le aller-retour par le sérialiseur est une méthode brutale mais exacte d’obtenir la sémantique PDF gratuitement. Un tableau ou dictionnaire direct se sérialise en son texte littéral et se décode en une instance fraîche et indépendante. Une référence indirecte se sérialise en 11 0 R et se décode en un nouvel objet référence pointant vers le même objet 11, si bien que la page réfère toujours à l’objet partagé au lieu de recevoir une copie inlinée, ce qui préserve le comportement par référence introduit en v3.539.27. La copie est exactement aussi profonde que la structure directe : tout ce qui s’atteint via une référence à l’intérieur d’un dictionnaire copié reste partagé, comme le format de fichier le veut. BalancePageTree appelle le même helper pour chaque page qu’il reparente, donc les pages matérialisées là obtiennent aussi des instances séparées
Copier ne suffit pas, parce que le cas référence pointe toujours vers un objet partagé. Si SetPageBox suivait cette référence et éditait l’objet 11, la page déplacée redimensionnerait à nouveau l’ancien parent et ses autres enfants. L’écrivain de box applique donc désormais le copy-on-write : il n’édite sur place que si l’entrée propre de la page est un tableau direct, et remplace une box indirecte ou absente par un nouveau tableau direct. L’objet 11 reste intact pour toutes les autres pages qui le citent
| Chemin de code | Avant v3.539.36 | Depuis v3.539.36 |
|---|---|---|
Matérialisation MovePage | La page détient les instances directes propres à l’ancêtre | La page détient des copies décodées ; les références restent des références |
SetPageBox | Suit une référence et édite le tableau partagé | N’édite qu’un tableau direct sur la page, sinon en écrit un nouveau |
Page source de CopyPageRanges | Partage les boxes du nœud Pages ; le CropBox est l’instance du MediaBox | Chaque valeur matérialisée sur la page source est une copie |
| Boxes par défaut au clonage des ressources de page | CropBox, BleedBox, TrimBox et ArtBox partagent un tableau | Chaque box par défaut reçoit son propre tableau |
La dernière ligne est le cas latent. Quand la bibliothèque clone les ressources d’une page pour capture ou fusion, elle comble les entrées CropBox, BleedBox, TrimBox et ArtBox manquantes, et celles-ci étaient autrefois la même instance de tableau. Aucun appelant actuel n’a laissé cet alias vivre assez longtemps pour être édité, mais le prochain l’aurait fait. Le choix de ces valeurs de box par défaut est un sujet à part, traité dans le guide PDFlibPas des valeurs par défaut de TrimBox, BleedBox et CropBox
Reproduire l’aliasing MovePage avec un PDF fait main
Le plus rapide pour vérifier n’importe quel build de PDFlibPas est un petit PDF écrit à la main chargé avec LoadFromString, où chaque numéro d’objet est connu d’avance. Le helper ci-dessous écrit une table de références croisées classique avec des offsets d’octets correctement calculés, si bien que le test ne dépend pas du comportement de récupération de l’analyseur pour les fichiers endommagés
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); // offset d’octet base 0 de "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 // chaque entrée fait exactement 20 octets
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;
Le document de test a deux nœuds Pages intermédiaires. Le nœud 3 porte un MediaBox indirect (objet 11, 400 par 300 points), un CropBox direct et un dictionnaire Resources direct, et possède deux pages. Le nœud 4 a un MediaBox format Letter et possède la troisième page. Déplacer la page 1 en position 3 la reparente sous le nœud 4, exactement le déplacement qui exige une matérialisation : sans elle, la page deviendrait une page Letter
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); // la page qu’on vient de déplacer
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');
// Inspectez l’ancien parent AVANT de sélectionner une autre page (voir plus bas)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // ancienne page 2, toujours sous le nœud 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) prend le type de box 1 pour MediaBox et 2 pour CropBox, et la dimension 2 pour la largeur. Avec l’origine par défaut en bas à gauche, SetPageBox(1, 0, 200, 200, 200) signifie gauche 0, haut 200, 200 de large et 200 de haut. Sur les builds entre v3.539.27 et v3.539.35, les contrôles de sœurs échouent : la modification du CropBox atterrit dans le tableau direct du nœud 3, et celle du MediaBox réécrit l’objet 11 à travers la référence
CopyPageRanges modifie-t-il le document source ?
Depuis v3.539.36, CopyPageRanges écrit toujours sur les pages sources, mais chaque valeur qu’il écrit est une copie séparée, si bien que les modifications ultérieures de la source restent locales à la page éditée. L’écriture elle-même est volontaire : la page source a besoin de MediaBox, CropBox, Rotate et Resources explicites avant que son dictionnaire soit cloné dans la cible, sinon la copie perdrait tout ce qu’elle a hérité. La renumérotation et la copie de la page dans la cible sont traitées dans la copie profonde d’objets entre documents dans PDFlibPas ; ce bug siégeait côté source, que la plupart des gens supposent n’être que lue par une copie
La sortie ne le montrait jamais. Partagées ou copiées, les valeurs matérialisées se sérialisent à l’identique, si bien que les deux documents sauvegardés étaient octet pour octet les mêmes avant et après la correction. Seule une modification du document source après la copie révélait l’alias :
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; // devient le document sélectionné
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // ne rétrécit que le 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); // la copie garde sa taille d’origine
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Avant v3.539.36, les deux pages ici héritaient du MediaBox direct du nœud racine, la copie attachait cette instance à la page source 1, puis l’attachait encore comme CropBox de la page 1. Rétrécir le CropBox rétrécissait donc le MediaBox, et redimensionner le MediaBox redimensionnait la page 2 à travers le nœud racine. Les workflows qui copient des pages puis continuent d’éditer la source, comme l’assemblage de scans recto verso en un seul PDF avant de rogner les originaux, sont l’endroit où ça se manifestait
Pourquoi l’aliasing d’instances est-il si difficile à tester ?
L’aliasing d’instances est difficile à tester parce que l’effet observable exige trois étapes dans un ordre précis : créer l’alias, muter un côté, puis inspecter l’autre côté avant que quoi que ce soit d’autre n’y touche. La plupart des tests ne font que la première étape et comparent la sortie sauvegardée, identique que l’alias existe ou non
Le piège d’ordre dans PDFlibPas est SelectPage. Sélectionner une page réapplique la police courante via SelectFont, ce qui enregistre cette police dans les ressources de la page. Une page sans /Resources propre se résout au dictionnaire de son parent, donc simplement sélectionner une telle page ajoute légitimement /Font au nœud Pages. Dans le test MovePage ci-dessus, sélectionner l’ancienne page 2 ajoute l’entrée Helvetica au nœud 3, ce qui est un comportement correct et pas une fuite. Voilà pourquoi le contrôle GetObjectToString(3) passe avant SelectPage(1) ; inversez les deux et le test échoue sur un build corrigé
Cette règle marque aussi ce que v3.539.36 laisse volontairement tranquille. Écrire une ressource sur une page qui hérite son dictionnaire Resources écrit dans le dictionnaire de l’ancêtre, et chaque sœur voit la nouvelle entrée. C’est l’héritage fonctionnant comme spécifié, pas du partage d’instances, et c’est inoffensif parce qu’ajouter un nom de police ou d’image à un dictionnaire partagé ne change pas le rendu des autres pages. Si vous avez besoin qu’une page cesse d’hériter, donnez-lui d’abord son propre dictionnaire Resources
Checklist pour le code de modèle objet PDF
Les leçons se généralisent à tout modèle objet PDF bâti sur un pool et des conteneurs de pointeurs, en Delphi ou ailleurs :
- Lors de la matérialisation d’attributs hérités selon ISO 32000-1 §7.7.3.4, copiez en profondeur les valeurs directes et gardez les références indirectes comme nouvelles références vers le même objet
- Ne faites jamais de
Addd’une instance existante vers un second conteneur à moins que le partage soit voulu et documenté ; la possession par un pool veut dire que le runtime ne se plaindra jamais - N’éditez sur place que ce que le nœud courant possède comme objet direct ; remplacez les valeurs indirectes ou héritées par un objet direct neuf (copy-on-write)
- Les valeurs par défaut dérivées d’une autre entrée, comme un CropBox tiré d’un MediaBox, ont besoin de leur propre instance
- Testez l’aliasing par des séquences muter-puis-inspecter sur l’autre détenteur, et vérifiez l’ordre des appels qui peuvent légitimement écrire entre deux
- Comparer la sortie sauvegardée ne prouve rien ici : valeurs partagées et copiées se sérialisent à l’identique jusqu’à la première modification
- Sur PDFlibPas, passez à v3.539.36 ou ultérieure si vous appelez
MovePage,CollateDocumentsEx,BalancePageTreeouCopyPageRangespuis éditez des boxes de page ou dessinez sur des pages
PDFlibPas expose l’édition de l’arbre de pages, la copie entre documents et le contrôle des boxes de page par une seule classe TPDFlib pour Delphi, C++Builder et Free Pascal. Voyez la page produit de la bibliothèque PDF Delphi PDFlibPas pour les éditions, les plateformes et la référence API complète