Technisch artikel

PDFium matrix prepend vs append in Delphi: pivotrotatie

PDF-affiene matrices gebruiken de rijvectorconventie van ISO 32000-1 §8.3.3, waarbij een punt de matrix van links vermenigvuldigt: point' = point * M. In de PDFium Component voor Delphi en C++Builder legt dat ene feit het hele API-oppervlak van TPdfMatrix vast: Multiply append, dus M := M * Op, terwijl PreMultiply prepend, dus M := Op * M

Elke klassieke transformatiebug herleidt tot die zin die achterstevoren wordt onthouden. De watermerk die netjes roteert in je testbestand en half buiten de pagina belandt in het klantbestand. De thumbnail die dubbel geroteerd tevoorschijn komt omdat de pagina al een kwartslag droeg. De stempel wiens offset perfect is op A4 en afdrijft op Letter. Geen van die zijn renderbugs; het zijn vermenigvuldigingsvolgordebugs, en ze zijn allemaal te verhelpen zodra je hardop kunt zeggen in welke ruimte elke bewerking is geschreven

De rijvectorconventie die de regels bepaalt

TPdfMatrix slaat de zes spec-benoemde elementen op en past ze precies toe zoals het formaat ze definieert, dus de transformatie zelf is waar de redenering begint. TPdfMatrix.TransformPoint berekent x' = x*a + y*c + e en y' = x*b + y*d + f, wat de zeselementvorm is die ISO 32000-1 §8.3.4 definieert voor de cm-operator die een matrix samenvoegt met de huidige transformatiematrix. Het paar (a, b) is de eerste rij, (c, d) de tweede, en (e, f) de translatierij. Column-vector-gewoonten opgedaan bij OpenGL of bij een cursus lineaire algebra zullen je hier op het verkeerde been zetten, en ze doen dat stilzwijgend, want een matrix in de verkeerde volgorde is nog steeds een perfect geldige matrix. Lees een samengestelde bewerking in de rijconventie van links naar rechts en de volgorde van toepassing volgt vanzelf: aangezien point * (M * Op) gelijk is aan (point * M) * Op, werkt een aangehangen bewerking op coördinaten die de bestaande matrix al heeft geproduceerd, dat wil zeggen op paginaruimte, terwijl een voorgevoegde bewerking werkt vóórdat de bestaande matrix draait, in de eigen invoerruimte van het object

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Append order: each call acts on what the previous calls produced.
    M.Scale(0.5, 0.5);                   // M := M * S   half size
    M.Rotate(90);                        // M := M * R   clockwise, degrees
    M.Translate(300, 400);               // M := M * T   then move on the page

    Pt := M.TransformPoint(0, 0);        // x*a + y*c + e, x*b + y*d + f
  finally
    M.Free;
  end;
end;

TPdfMatrix.Rotate staat standaard op kloksgewijs en op graden, met ACounterClockwise en AAngleInRadians beschikbaar wanneer je brongegevens andersom zijn ondertekend. De alleen-lezen eigenschappen a tot en met f en de eigenschap Handle geven je de ruwe FS_MATRIX terug, wat is wat FPDFPageObj_SetMatrix wil. Niets in de klasse verbergt de zes getallen voor je, en dat is opzettelijk: wanneer een transformatie zich misdraagt, is het afdrukken van a tot en met f de snelste diagnose die je hebt

Waarom heeft het prependen van een translatie het lineaire deel nodig

Omdat een voorgevoegde verschuiving is geschreven in de invoerruimte van de matrix, en ze door het huidige lineaire deel gedragen moet worden voordat ze zich bij de translatierij kan voegen. TPdfMatrix.PreTranslate berekent daarom e := dx*a + dy*c + e en f := dx*b + dy*d + f. Appenden is de gemakkelijke richting: TPdfMatrix.Translate is geschreven in paginaruimte, waar niets geconverteerd hoeft te worden, dus het voegt alleen dx toe aan e en dy aan f. Wie PreTranslate "optimaliseert" tot twee optellingen, heeft zojuist de rotatie en schaal uit de verschuiving verwijderd

M := TPdfMatrix.Create;
try
  M.Rotate(90);              // a=0, b=-1, c=1, d=0

  M.Translate(10, 0);        // append: e := e + 10
                             // -> 10 points to the right on the page

  M.Reset;
  M.Rotate(90);
  M.PreTranslate(10, 0);     // prepend: e := 10*a + 0*c + e  (unchanged)
                             //          f := 10*b + 0*d + f  (f - 10)
                             // -> 10 points along the stamp own x axis,
                             //    which after the turn points down the page
finally
  M.Free;
end;

Dezelfde asymmetrie loopt door het schaalpaar, en het is de moeite waard om te weten welke elementen elk ervan raakt voordat je er om drie uur s nachts een debugt. TPdfMatrix.PreScale vermenigvuldigt rijen, schaalt a en b met scaleX en c en d met scaleY, en laat de translatie met rust omdat de verschuiving stroomafwaarts al heeft plaatsgevonden. Het appenderende TPdfMatrix.Scale vermenigvuldigt in plaats daarvan kolommen, waarbij a, c, e met scaleX worden vermenigvuldigd en b, d, f met scaleY, zodat de bestaande offset meeschaalt met al het andere. Beide zijn single-purpose paden die het algemene zeselementproduct overslaan, en beide behouden de compositiesemantiek van de algemene vorm precies

Waar gaan de twee translaties in een pivotrotatie

Rond de bewerking, niet rond de hele matrix, en in die volgorde. TPdfMatrix.RotateAt append Translate(-pivot), dan de rotatie, dan Translate(+pivot), wat onder de rijvectorconventie samenstelt als Translate(-pivot) * Op * Translate(pivot). Die volgorde is wat de pivot vast houdt onder de nieuwe bewerking terwijl de bestaande matrix zijn coördinaten toch eerst laat produceren en doorgeeft. Schrijf het paar in de andere volgorde, zoals correct zou zijn in een column-vector-bibliotheek, en het object draait rond de oorsprong in plaats van ter plekke te tollen, wat precies is hoe een gecentreerd watermerk buiten de crop box eindigt

procedure RotateStampAboutPageCenter(AObj: FPDF_PAGEOBJECT;
  const AAngleDegrees, APageWidth, APageHeight: Single);
var
  M: TPdfMatrix;
  Raw: FS_MATRIX;
begin
  if not FPDFPageObj_GetMatrix(AObj, Raw) then
    raise Exception.Create('Page object carries no matrix');
  M := TPdfMatrix.Create(Raw);
  try
    // Appends Translate(-pivot) * Rotate * Translate(+pivot) in one call.
    M.RotateAt(AAngleDegrees, APageWidth / 2, APageHeight / 2);
    Raw := M.Handle;
    FPDFPageObj_SetMatrix(AObj, Raw);
  finally
    M.Free;
  end;
end;

Dezelfde compositie ondersteunt ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt en CentralFlipAt, dus zodra je het patroon voor rotatie vertrouwt, kun je het ook voor de rest vertrouwen. TPdfMatrix.CentralFlip is het apart noemen waard: het negeert alle zes elementen om je een draai van 180 graden te geven zonder enige trigonometrie, wat betekent geen cos van een waarde die precies nul had moeten zijn en geen accumulerende drift wanneer je het in een lus toepast. Als je herhaalde markeringen plaatst in plaats van er één te draaien, wordt de mechaniek van de plaatsing zelf behandeld in herbruikbare paginastempels met Form XObjects, en het matrixwerk hier zit er rechtstreeks bovenop

Wat vertelt TryDecompose je over een matrix

TPdfMatrix.TryDecompose rapporteert translatie, schaal, rotatie, afschuiving, determinant en een spiegelvlag onder een schaal-dan-rotatie-conventie, en het rapporteert ze eerlijk genoeg om bruikbaar te zijn voor beslissingen in plaats van alleen voor logging. ScaleX komt uit de lengte van de eerste rij, Sqrt(a*a + b*b), dus is altijd positief. ScaleY is dan Determinant / ScaleX, wat het getekend maakt. Rotatie komt uit ArcTan2(-b, a) in graden, en afschuiving uit het dotproduct van de twee rijen genormaliseerd door beide schalen

Dat teken op ScaleY is het deel dat mensen verwijderen, en het verwijderen ervan is een echte bug, geen cosmetische. Een negatieve determinant betekent dat de matrix een spiegeling bevat. Forceer beide schaalfactoren positief om de getallen er netter uit te laten zien en je hebt de spiegeling weggegooid, zodat een matrix die uit de decompositie is herbouwd, gespiegeld terugkomt: tekst leest achterstevoren, een gescande pagina flipt, een geïmporteerd logo kijkt de verkeerde kant op. Het veld IsReflected bestaat zodat je het nooit hoeft af te leiden. Dit is ook de controle die de klassieke dubbele rotatie voorkomt, waarbij code een weergavedraai toevoegt aan een pagina die er al één draagt; de viewer-zijdige versie van dat probleem wordt uitgewerkt in thumbnail fit, zoom en dubbele rotatie

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX is always positive; D.ScaleY carries the determinant sign.
    if D.IsReflected then
      Log('mirrored, ScaleY = %.3f', [D.ScaleY]);

    if Abs(D.RotationDegrees) > 0.5 then
      SkipDisplayRotation;      // the object already carries its own turn
  end
  else
    UseIdentityFallback;        // near-singular or non-finite: no answer
end;

Eén rechthoek in een andere passen zonder gokken

TPdfMatrix.TryCreateRectMapping bouwt de bron-naar-bestemming-matrix voor je op en neemt een TPdfMatrixFitMode van pmfStretch, pmfContain, of pmfCover. Het normaliseert eerst beide rechthoeken, omdat PDF-rechthoeken niet verplicht zijn om aan te komen met links onder rechts of onder onder boven, en leidt dan onafhankelijke X- en Y-schalen af: pmfStretch houdt ze onafhankelijk, pmfContain neemt de kleinste en centreert de letterbox, pmfCover neemt de grootste en centreert de crop. De begeleidende MapRectToRect append dezelfde mapping op een bestaande matrix, en NewRectMapping werpt EPdfMatrixError op waar de Try-vorm False teruggeeft. Dit is het primitief onder elke celplaatsing in N-up-impositie en pagina-herordening, waar elke bronpagina in een berekende cel moet landen zonder dat je de rekenkunde per lay-out opnieuw afleidt

Degenerate matrices en het eerlijke faalpad

Eindige invoer garandeert geen eindig resultaat, dus berekent de fitting-code in Double en controleert daarna de versmalde Single-kandidaat opnieuw op eindigheid voordat hij wordt gepubliceerd; een mapping die een oneindigheid bevat wordt nooit teruggegeven alsof hij geldig was. Dezelfde discipline regelt de inversie. TPdfMatrix.TryGetInverse wijst een matrix af met een relatieve drempel, door de determinant te vergelijken met epsilon maal het kwadraat van het grootste lineaire element in plaats van met een vaste constante, wat de test betekenisvol houdt of je eenheden nu punten of micrometers zijn. TryDecompose haakt op dezelfde manier af en weigert wanneer de lengte van de eerste rij of de afgeleide ScaleY op of onder epsilon valt

Kies de faalstijl die bij de aanroeplocatie past in plaats van uit gewoonte alles in try-except te verpakken. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds en TryCreateRectMapping geven False terug en laten hun doelen onaangeroerd, wat past bij hit-testing en per-object-lussen waar een degenerate object overgeslagen moet worden, niet fataal moet zijn. Invert, InverseCopy, InverseTransformPoint, MapRectToRect en TransformBounds werpen in plaats daarvan EPdfMatrixError op, wat past bij setupcode waar een singuliere matrix betekent dat de aanroeper iets verkeerd heeft berekend. Voor batchwerk alloceren TransformPoints en TransformRects hun resultaatarray precies één keer, hergebruiken TransformPointsInPlace en TransformRectsInPlace je eigen opslag, en accumuleert TryTransformBounds de bounding box in één doorgang in plaats van eerst getransformeerde punten te materialiseren

Niets hiervan is exotische wiskunde. Het is één conventie, consistent toegepast, met de API zo benoemd dat de conventie zichtbaar is op de aanroeplocatie: Multiply en de gewone werkwoorden appenden, de Pre-familie prepend, de At-familie omsluit de bewerking met zijn pivotpaar. Schrijf de volgorde op in een commentaar naast elke samengestelde bewerking die je bouwt, want de code die vandaag correct leest is de code die iemand over zes maanden omdraait. De volledige TPdfMatrix-referentie, samen met de page-object- en render-API's waar deze transformaties in stromen, staat bij de PDFium Component voor Delphi en C++Builder