Technisch artikel

PDFlibPas content-stream-toestand: CTM- en clip-tracking

PDFlibPas, de native VCL-PDF-componentbibliotheek voor Delphi en C++Builder, speelt de inhoudsstroom van een pagina af via zijn klasse TPDFContentStateTracker zonder ook maar een render-canvas aan te raken. Eén geparste operator tegelijk aan de tracker voeren houdt een lopende grafische-toestandregistratie bij — huidige transformatiematrix, tekstmatrix, clipgrenzen, en de q/Q-bewaarstack — beschikbaar voor een momentopname vóór of na het uitvoeren van elke operator

Vraag waar een stuk tekst daadwerkelijk terechtkomt op de afgedrukte pagina, en de ruwe getallen van de inhoudsstroom alleen zullen u telkens misleiden. TPDFContentProgram.GetTextRuns rapporteert het ankerpunt van elke tekstweergave-instructie al via de velden OriginX en OriginY op TPDFTextRun, en de veldcommentaren zijn expliciet dat dit punt zich in tekstruimte bevindt, al gevouwen door Tm, Td, TD, en T*. Wat nog ontbreekt, en wat die commentaren zeggen dat een aanroeper zelf moet aanleveren, is de CTM die actief was op precies die instructie — het product van elke tot dan toe samengevoegde cm, genest binnen hoeveel q/Q-paren er op dat punt in de stroom ook open staan

Waarom een inhoudsstroom afspelen in plaats van deze te renderen?

PDFlibPas houdt twee aparte noties van grafische toestand bij voor twee aparte taken, en die scheiding is doelbewust. Het interne toestandsrecord van de renderer draagt een levende device-canvas-handle, een clipping-regio-handle, en lettertype-rasterisatiecaches — echte resources gekoppeld aan welk oppervlak er ook op dat moment wordt geschilderd, en zinloos zodra dat oppervlak verdwijnt. TPDFContentGraphicsState draagt niets daarvan: het is een gewoon record beperkt tot de waarden die ISO 32000-1 §8.4 definieert als alleen bereikbaar vanuit inhoudsstroom-operatoren — de CTM, lijnstijl, kleur, teksttoestand, en de afgeleide clip- en padgrenzen. Omdat het record geen canvasverwijzing en geen open bestandshandle draagt, kan een aanroeper een inhoudsstroom parsen, deze doorlopen met TPDFContentStateTracker, en de resulterende momentopnames blijven gebruiken lang nadat wat de bytes ook produceerde is verdwenen

Hoe bouwt TPDFContentStateTracker de CTM op?

TPDFContentStateTracker.Apply voegt de zes operanden van een cm-operator samen in de CTM van de tracker met dezelfde premultiply die PDF zelf specificeert: de nieuwe matrix M2 combineert met de huidige CTM als M2 × CTM, in de rijvector-conventie waarin een punt transformeert als P′ = P × M (ISO 32000-1 §8.4). Het deel dat gemakkelijk verkeerd gaat, zit in de translatieterm, niet in het lineaire deel: de eigen translatie van M2 moet door het rotatie-en-schaal-component van de huidige CTM heen voordat de translatie van de huidige CTM erbovenop wordt opgeteld. Sla die stap over en hardcodeer in plaats daarvan een naïeve componentsgewijze combinatie, en de eerste geïsoleerde cm die u test, ziet er correct uit terwijl elke coördinaat stroomafwaarts van een tweede of derde geneste cm stilzwijgend afdrijft, precies het soort bug dat een code review overleeft omdat de unittest die het zou vangen minstens twee gekoppelde transformaties nodig heeft om te falen

var
  Prog: TPDFContentProgram;
  Runs: TPDFTextRunArray;
  States: TPDFContentGraphicsStateArray;
  DeviceX, DeviceY: Double;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  try
    Prog.Parse(ContentBytes);
    Runs := Prog.GetTextRuns;
    // One before-instruction snapshot per operator, computed in a single pass
    States := Prog.TraceGraphicsStates(nil, False);
    for I := 0 to High(Runs) do
    begin
      // OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
      // at this instruction is still missing (ISO 32000-1 8.4)
      with States[Runs[I].InstructionIndex].CTM do
      begin
        DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
        DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
      end;
      LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
    end;
  finally
    Prog.Free;
  end;
end;

De bovenstaande lus beantwoordt het knelpunt uit de opening: TPDFContentProgram.GetTextRuns geeft OriginX en OriginY terug al gevouwen door Tm, Td, TD, en T*, en TraceGraphicsStates(nil, False) levert het ene resterende stukje, de before-instruction-CTM bij precies de index waarop elke run is vastgelegd, in één lineaire doorgang over het hele programma. Nil doorgeven laat de methode een private tracker voor de aanroep bezitten en deze intern vrijgeven, wat de juiste keuze is voor een eenmalige scan; in plaats daarvan een bestaande TPDFContentStateTracker-instantie doorgeven is wat de toestand continu houdt over een pagina die uit meer dan één inhoudsstroom is samengesteld, aangezien ISO 32000-1 de /Contents-array van een pagina als één logische stroom behandelt en de q/Q-stack het daarmee eens moet zijn

De tekstmatrix overleeft Q; de grafische toestand niet

ISO 32000-1 §9.4.2 definieert Td, TD, Tm, en T* als de operatoren die de tekstmatrix en tekstregelmatrix binnen een BT/ET-blok opbouwen, en PDFlibPas houdt dat onderscheid scherp: Td en TD voegen een pure translatie samen aan de tekstregelmatrix, T* doet hetzelfde met de negatieve waarde van de huidige leading, en alleen Tm vervangt beide matrices helemaal door de zes getallen die het krijgt. BT reset beide matrices naar de identiteit, precies één keer, aan het begin van het tekstobject — maar q en Q raken ze helemaal niet aan. TPDFContentStateTracker.Apply behandelt coRestoreState precies om deze reden als speciaal geval: voordat het de bewaarde toestand van de stack haalt, legt het de huidige tekstmatrix, tekstregelmatrix, en BT/ET-vlag vast, en past deze opnieuw toe over wat de gehaalde toestand ook bevatte, omdat een q/Q-paar rond een tekstrun niet verondersteld wordt de tekstpositie terug te verplaatsen

var
  Tracker: TPDFContentStateTracker;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  Tracker := TPDFContentStateTracker.Create;
  try
    Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
    for I := 0 to Prog.Count - 1 do
    begin
      Tracker.Apply(Prog[I]);
      if Prog[I].Op in [coShowText, coRestoreState] then
        LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
    end;
  finally
    Tracker.Free;
    Prog.Free;
  end;
end;

Draai die reeks en de CTM gerapporteerd bij de tweede Tj is terug naar de identiteitsschaal die het had vóór q — de 2 0 0 2 0 0 cm binnen het save/restore-paar is weg, zoals q/Q vereist. TextMatrix.DX bij diezelfde instructie is echter nog steeds 100: de Td die deze instelde, draaide vóór de q, dus het is geen grafische toestand die de Q ooit gerechtigd was aan te raken, en een tool dat anders zou aannemen, zou de tweede glyph-run vanaf de verkeerde horizontale positie op de pagina laten beginnen rapporteren

Wat gebeurt er wanneer een clippad-operator draait?

Een W- of W*-operator verkleint de clip niet onmiddellijk; het registreert alleen welke vulregel te gebruiken, en de daadwerkelijke doorsnede wacht op welke padschilder-operator er ook op volgt, inclusief de no-op-schilder n die PDF-auteurs routinematig gebruiken precies om te clippen zonder iets te tekenen. TPDFContentStateTracker weerspiegelt die tweestaps-timing precies: coClip en coClipEvenOdd stellen alleen een wachtende clip-regel-vlag in, en EndCurrentPath — aangeroepen door elke padschilder-operator — is wat de grenzen van het wachtende pad daadwerkelijk doorsnijdt in ClipMinX, ClipMinY, ClipMaxX, en ClipMaxY. Deze fasering goed krijgen doet ertoe voor het before/after-momentopname-contract zelf: een before-momentopname genomen precies bij de W-instructie moet nog steeds de oude, bredere clip tonen, omdat de clip op dat punt in de stroom nog niet van kracht is, en de twee stappen samenvoegen tot één zou stilzwijgend elke aanroeper breken die erop vertrouwt dat before-state betekent wat het zegt

ClipBoundsExact vertelt een aanroeper met welke van twee situaties het te maken heeft, en is alleen ooit True voor één enkele as-uitgelijnde rechthoek gebouwd door re op een verder leeg pad — de ene vorm die PDFlibPas exact kan representeren als vier getallen. Al het andere — een gedraaide rechthoek, een gebogen omtrek, een samengesteld pad met verschillende subpaden, of een clip opgebouwd uit een tekstweergavemodus — produceert nog steeds ClipMinX tot en met ClipMaxY, maar met ClipBoundsExact op False gezet, een eerlijk signaal dat de vier getallen een veilige buitengrens zijn en niet de werkelijke clipvorm; aanroepers die alleen die grens nodig hebben, zoals het isoleren van een rechthoekig subgebied vóór de GDI-halftoon-omzetting die wordt beschreven in het renderen van PDF-pagina's naar 1-bit monochroom, kunnen deze rechtstreeks lezen in plaats van deze opnieuw af te leiden uit de paginageometrie

Bézier-curven: een exacte grens of een veilige

De goedkoopste manier om een kubisch Bézier-segment te begrenzen, is de convexe romp van de vier controlepunten te nemen, en dat is altijd veilig omdat de curve deze nooit verlaat — maar een ondiepe, brede curve kan een begrenzingskader rapporteren dat veel groter is dan de curve daadwerkelijk beslaat, wat clip-gebaseerde filtering precies verzwakt wanneer het er het meest toe doet, op grote decoratieve paden. PDFlibPas lost het strakkere probleem in plaats daarvan op: voor elke as lost het de afgeleide van de kubische curve op voor wortels binnen het open interval (0, 1) en evalueert het de curve bij eventuele gevonden wortels, samen met beide eindpunten, wat de standaard gesloten-vorm-manier is om de werkelijke as-uitgelijnde omvang van een curve te krijgen in plaats van een overschatting. Precisie per curve draagt echter niet over naar de clip zelf: zodra een gebogen omtrek een clippad wordt, valt ClipBoundsExact er nog steeds op False voor, omdat een begrenzingskader, hoe strak ook, nog steeds niet dezelfde vorm is als de curve die het begrenst, en de toestandstracker liever dat aangeeft dan een aanroeper een rechthoek te laten aannemen waar er eigenlijk een curve is

Toestand lezen vóór en na elke operator

Of een aanroeper de before- of after-toestand wil, hangt volledig af van wat de operator doet: een teken- of hit-testing-vraag over een pad of tekstrun wil de toestand zoals die er stond het moment vóór die operator draaide, aangezien dat daadwerkelijk bepaalde hoe de operator schilderde, terwijl een diagnostische vraag over een toestand-instellende operator zoals gs meestal wil zien wat het net heeft veranderd. TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) stelt precies die keuze bloot als één enkele Booleaanse waarde, en berekent één TPDFContentGraphicsState per instructie in één lineaire doorgang over het hele programma, ongeacht welk moment wordt opgevraagd. GetGraphicsState(InstructionIndex, AfterInstruction, State) biedt dezelfde before/after-keuze voor één enkele instructie in plaats van het hele programma, maar speelt bij elke aanroep vanaf instructie nul opnieuw af om daar te komen, dus veel indices scannen door het in een lus aan te roepen kost O(n²) tegenover één enkele O(n)-aanroep van TraceGraphicsStates over hetzelfde programma

var
  Before, After: TPDFContentGraphicsState;
begin
  // Same instruction index, two different instants: before vs. after it runs
  Prog.GetGraphicsState(CmIndex, False, Before);
  Prog.GetGraphicsState(CmIndex, True, After);
  // Before.CTM reflects every earlier cm; After.CTM already folds in
  // this instruction's own concatenation as well
end;

Leven met misvormde inhoudsstromen

Twee soorten misvormde invoer komen vaak genoeg voor bij echte PDF-producenten dat TPDFContentStateTracker deze moet tolereren in plaats van erop te falen. De eerste is een pad dat een q/Q-grens overspant: het huidige pad, het huidige punt, en het aantal subpaden zijn geen grafische-toestand-parameters — ISO 32000-1 §8.4 behandelt wat q en Q bewaren en herstellen, en het pad dat wordt opgebouwd hoort daar niet bij — dus TPDFContentStateTracker volgt die data volledig buiten de bewaarde toestand, en een subpad dat vóór een q is begonnen, staat er nog, ongeschilderd, direct na de bijbehorende Q. De tweede is een kale Q zonder bijbehorende q ergens ervoor in de stroom, niet zeldzaam in output van generators die inhoudsstroomfragmenten door samenvoeging opbouwen en de boekhouding verkeerd doen. TPDFContentStateTracker.RestoreUnderflowCount telt elk van die gebeurtenissen in plaats van een uitzondering op te werpen of de toestand te corrumperen: een ongepaarde Q laat de huidige grafische toestand gewoon precies zoals die was, alsof die instructie een no-op was geweest, dus de rest van de stroom blijft afspelen op een gezonde toestand en een aanroeper kan achteraf, aan de hand van de telling, alsnog beslissen of de invoer het waard is om terug te melden aan wie deze ook produceerde

CTM-samenstelling, de onafhankelijkheid van de tekstmatrix van q/Q, en de gefaseerde realisatie van een clippad hangen niet af van hoe of of de inhoudsstroom ooit wordt geschilderd, en dat is precies het punt: dezelfde TPDFContentStateTracker-momentopname is correct of de pagina nu nooit wordt gerenderd of op het punt staat te worden overgedragen aan welke backend PDFlibPas voor dat bestand ook selecteert, inclusief het wisselen van runtime-engine dat wordt behandeld in de gids over multi-engine-PDF-rendering in PDFlibPas. Inhoudsanalyse, coördinaattoewijzing, en redactietooling kunnen allemaal volledig draaien op de output van de tracker, lang voordat of volledig zonder ooit een renderer te vragen erbij betrokken te raken

Inhoudsstroom-replay via TPDFContentStateTracker maakt deel uit van het gestructureerde inhoudsbewerkingsraamwerk ingebouwd in PDFlibPas, de native VCL-PDF-componentbibliotheek voor Delphi en C++Builder