Een content stream die eerst /F1 1 Tf zet, dan 12 0 0 12 72 700 Tm en dan (Hello world) Tj toont, is volstrekt legaal. Een zelfgeschreven renderer tekent die elf tekens vaak allemaal binnen zo'n vijfeneenhalve punt — een donkere vlek aan de linkermarge waar een woord van 66 punten hoort te staan. De rekenkunde achter die vlek is één regel lang. ISO 32000-1 §9.4.4 definieert de glyph-advance in tekstruimte, dus de verplaatsing moet eerst door de tekstmatrix voordat hij de pagina bereikt, en een clip die binnen q ... Q wordt gezet hoort evenzeer bij de grafische status als de lijndikte
Waarom trekt tekst samen tot een vlek zodra een PDF Tf 1 gebruikt?
Tekst trekt samen omdat de renderer een afstand in tekstruimte rechtstreeks bij de translatiecomponent van Tm optelt, wat alleen werkt zolang tekstruimte en user space dezelfde schaal hebben. Legio echte producenten schrijven de fontgrootte als 1 en dragen de echte grootte in de tekstmatrix mee. Onder /F1 1 Tf en 12 0 0 12 72 700 Tm schuift een glyph van 500 units breed 0.5 op in tekstruimte. Tm schaalt dat naar 6 punten op de pagina. Een renderer die Tm.e := Tm.e + Adv uitvoert, zet de pen maar 0.5 punt opzij. Elke glyph landt dan één twaalfde teken na de vorige, en een viewer die §9.4.4 volgt, toont hetzelfde bestand als gewone lopende tekst
De snelkoppeling overleeft lang, want de meeste generators waarmee mensen testen schrijven /F1 12 Tf met een ongeschaalde Tm, en dan zijn tekstruimte en user space echt even groot. Hier is de naïeve versie in zijn geheel, met de content stream die hem breekt als commentaar bovenaan:
// Content stream van een producent die de grootte in Tm codeert, niet in Tf:
// BT
// /F1 1 Tf
// 12 0 0 12 72 700 Tm
// [(Hel) 30 (lo) -250 (world)] TJ
// ET
// Naïeve advance: een afstand in tekstruimte die rechtstreeks bij Tm.e wordt opgeteld alsof het user space is
Adv := W * FontSize / 1000; // 0.5 voor een glyph van 500 units
if (HorizScale <> 0) and (HorizScale <> 100) then
Adv := Adv * HorizScale / 100; // Th alleen op de breedte
Adv := Adv + CharSpace; // Tc niet geschaald door Th
if Code = 32 then
Adv := Adv + WordSpace * FontSize / 1000; // Tw ten onrechte geschaald door Tfs
Tm.e := Tm.e + Adv; // negeert Tm.a, Tm.b, Tm.c, Tm.d
// Naïeve TJ-correctie: geen Th, en weer alleen Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;
De Tm.e-regel is de luidruchtige bug. De andere zijn stiller en komen op andere bestanden bovendrijven. Woordafstand Tw staat al uitgedrukt in ongeschaalde tekstruimte-units, dus vermenigvuldiging met FontSize / 1000 maakt van een 3 Tw onder Tf 12 nog maar 0.036 units. Een uitgevulde alinea verliest vrijwel zijn hele tussenruimte. Horizontale schaling Th moet ook op Tc en Tw toegepast worden, en op het kerning-getal van TJ. De test Code = 32 slaat bovendien toe op elke byte met waarde 32 binnen een two-byte code, wat willekeurige gaten door CJK-tekst strooit. Dit soort tekststatusfouten faalt zelden luid: net als de operand-index- en resourcenaam-valkuilen die Tc, Tw en Tz op nul kunnen zetten voordat er ook maar één glyph getekend is, leveren ze geloofwaardige pagina's op bij de ene producent en kapotte bij de volgende
Hoe definieert ISO 32000-1 §9.4.4 de glyph-advance?
ISO 32000-1 §9.4.4 rekent de advance volledig in tekstruimte uit en past hem als translatiematrix op de tekstmatrix toe, dus schaling, rotatie en schuine stand komen allemaal uit Tm en nooit uit de advance zelf. Voor horizontaal schrijven geldt tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, waarbij w0 de glyphbreedte is in duizendsten van een em, Tj de TJ-correctie en Th Tz gedeeld door 100. De nieuwe matrix is [1 0 0 1 tx 0] × Tm. Uitgeschreven telt dat product a·tx op bij e en b·tx bij f, en daarom kan een translatie in tekstruimte nooit een kale optelling bij e zijn, tenzij a 1 is en b 0. Volgens §9.3.3 geldt Tw alleen voor de single-byte charactercode 32
De HotPDF Delphi Component houdt die formule op één plek bij. De unit HPDFContentStream exporteert HPDFTextGlyphDisplacement voor de advance per glyph en HPDFTextAdjustmentDisplacement voor een TJ-getal, en zowel de pagina-renderer als de interpretator voor tekstextractie roept ze aan, dus een pagina die correct rendert, meldt ook glyphposities die aansluiten op wat je ziet. Schrijf je een eigen content-stream-consument bovenop de parser van de bibliotheek, dan kun je dezelfde twee routines aanroepen en de matrixstap zelf leveren:
uses
HPDFContentStream; // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement
type
// Eigen tekstmatrix-record, geen HotPDF-type
TTextMatrix = record
a, b, c, d, e, f: Double;
end;
// Eigen helper: [1 0 0 1 Tx Ty] x Tm, lopend via a, b, c en d
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
M.e := M.e + M.a * Tx + M.c * Ty;
M.f := M.f + M.b * Tx + M.d * Ty;
end;
// W0 is de glyphbreedte in 1/1000 em; Tz is de Tz-operand (100 = geen schaling)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
Tx, Ty: Double;
begin
HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
(Code = 32) and not TwoByteCodes, // Tw alleen op de single-byte spatie
Tc, Tw, Tz, Tx, Ty);
TranslateInTextSpace(Tm, Tx, Ty);
// Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 en Tm.e groeit met 6
end;
procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
Tx, Ty: Double;
begin
HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
TranslateInTextSpace(Tm, Tx, Ty); // Th geldt ook voor kerning
end;
Binnen de renderer stuurt dezelfde matrixstap elke operator die de tekstpositie verplaatst. Td, TD, T*, ' en " transleren de regelmatrix via zijn coëfficiënten, dus 0 -1.2 Td onder een tekstmatrix van 12× zet de basislijn 14.4 punt omlaag in plaats van 1.2. Glyphs die nooit geschilderd worden volgen dezelfde weg. Rendermodus 3, de onzichtbare modus die OCR-tekstlagen gebruiken, en tekst in verborgen optionele content schuiven Tm precies evenveel op als geschilderde tekst. Er een privé-exemplaar van die rekenkunde voor aanmaken is de klassieke fout, want dan begint zichtbare tekst die op een onzichtbare reeks in hetzelfde BT-blok volgt op een iets andere positie
Het plaatsen van de glyph zelf
De glyphplaatsing volgt dezelfde logica. Wanneer er geen ingesloten outline beschikbaar is en de renderer terugvalt op GDI TextOutW, bouwt hij de volledige glyphmatrix uit CTM × Tm × rise × em-schaal, met Th erin. Die matrix gaat via SetWorldTransform in GM_ADVANCED-modus naar binnen, binnen een SaveDC / RestoreDC-paar. Het GDI-font wordt gemaakt op een vaste hoogte van 1000 units en de world transform doet alle maatvoering. Geroteerde en scheve tekst behoudt daardoor zijn oriëntatie in plaats van rechtop getekend te worden op een getransformeerde oorsprong. Rendermodi die met stroke tekenen zetten de world transform terug voordat ze de omtrek trekken, anders krimpt een penbreedte gemeten tegen een ontwerpgrootte van 1000 units tot vrijwel niets. Verticaal schrijven is de enige bewuste asymmetrie: een WMode-1-font schuift langs de y-as naar beneden over zijn verticale metrie, Tc en Tw gelden langs die as, en Th wordt helemaal niet toegepast
Wat bewaart q/Q eigenlijk op in de grafische status van een PDF?
ISO 32000-1 §8.4.2 noemt het huidige clipping-path deel van de grafische status, dus Q moet de clip exact herstellen zoals hij stond bij de bijbehorende q. Dat klinkt vanzelfsprekend. In een renderer op GDI-basis is het gemakkelijk te missen, want de CTM, kleuren, lijnparameters en tekststatus wonen in de eigen statusstack van de renderer, terwijl de clip in de device context woont — buiten die stack. Alleen de numerieke status terughalen herstelt alles behalve de clip. Een W n binnen een q ... Q-blok snijdt dan elke latere bewerking op de pagina bij, en het symptoom ziet eruit als ontbrekende content ver weg van de operator die hem veroorzaakte
Form XObjects openen een tweede route naar hetzelfde falen. §8.10 geeft een formulier een impliciete save en restore rond zijn content en clippt hem op zijn /BBox, en echte formuliercontent laat zijn eigen q en Q soms ongepaard achter, ook al eist de specificatie dat ze paren. HotPDF legt de clip vast en bewaart de device context voordat een formulier draait, en als het formulier klaar is, gooit hij alle clipframes weg die het formulier open liet staan en herstelt de device context, zodat elke vastgelegde regio precies één keer wordt vrijgegeven. Een verdwaalde Q in het formulier die de status van de aanroeper zou wegpoppen, wordt geweigerd in plaats van toegepast. Met RenderFallbackPolicy op rfpReport verschijnt die weigering in de diagnostiek, waarmee je een slordige producent gemakkelijk onderscheidt van een renderprobleem:
// Diags: THPDFRenderFallbackDiagnostics (gedeclareerd in HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
Diags := Pdf.GetLastRenderFallbackDiagnostics;
for J := 0 to High(Diags) do
if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
Log.Add(Format('page %d: unbalanced Q refused - %s',
[PageIndex + 1, string(Diags[J].Reason)]));
finally
Bmp.Free; // Free is veilig op nil
end;
De clip alleen vastleggen wanneer een frame hem verandert
De voor de hand liggende oplossing is de GDI-clipregio bij elke q uitlezen en bij elke Q terugzetten. Dat is correct. Het is ook duur op precies de pagina's die q het meest gebruiken, want generators verpatten graag elke kleine tekening in q 1 0 0 1 x y cm ... Q, puur om de oorsprong te verplaatsen. Elk van die paren zou dan een GDI-regio aanmaken, de clip erin kopiëren en hem weer verwijderen. Als meerdere pagina's parallel renderen, gaan de threads uiteindelijk om GDI-regioobjecten wedijveren in plaats van rasteren, en de snelheidswinst uit de parallelle renderpipeline slinkt mee
HotPDF stelt het dure deel uit. Een q legt alleen de stackdiepte en het bijbehorende oppervlak vast, in een frame-array die vanaf 16 verdubbelt en op de pagina hergebruikt wordt, dus een reeks numerieke transformaties reserveert helemaal geen GDI-object. De clip wordt vastgelegd zodra voor het eerst iets in dat frame op het punt staat hem te veranderen: het schilderen van een pad of n met een hangende W of W*, een patroonvulling, de clip die tekstrendermodi 4 tot en met 7 achterlaten, of de intocht in een formulier. Bij Q zet een vastgelegd frame zijn regio terug en geeft haar vrij, en een frame dat de clip nooit veranderde popt alleen de numerieke status. De gewone pagina reserveert dan helemaal geen regio, en een pagina die clippt betaalt exact één regio per echte clipverandering. Bouw je een eigen GDI-apparaat voor een PDF-interpreteerder, dan past hetzelfde patroon in een paar dozijn regels:
// Een schets voor je eigen GDI-apparaat; niets hiervan is HotPDF-API
// heeft Windows, SysUtils en Math nodig in de uses-clausule
type
TClipFrame = record
Region: HRGN;
Captured: Boolean;
end;
procedure TMyDevice.PushState; // q: helemaal geen GDI-aanroep
begin
if FCount = Length(FFrames) then
SetLength(FFrames, Max(16, Length(FFrames) * 2));
FFrames[FCount].Region := 0;
FFrames[FCount].Captured := False;
Inc(FCount);
end;
procedure TMyDevice.BeforeClipChange; // aanroepen vóór W n, W f, tekstclip, form-BBox
var
R: HRGN;
Res: Integer;
begin
if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
R := CreateRectRgn(0, 0, 0, 0);
if R = 0 then RaiseLastOSError;
Res := GetClipRgn(FDC, R); // 1 gekopieerd, 0 geen clip, -1 fout
if Res <= 0 then
begin
DeleteObject(R);
R := 0;
if Res < 0 then RaiseLastOSError;
end;
FFrames[FCount - 1].Region := R;
FFrames[FCount - 1].Captured := True;
end;
procedure TMyDevice.PopState; // Q
begin
if FCount = 0 then Exit; // ongepaarde Q: negeren
Dec(FCount);
if FFrames[FCount].Captured then
SelectClipRgn(FDC, FFrames[FCount].Region); // Regio 0 verwijdert de clip
if FFrames[FCount].Region <> 0 then
DeleteObject(FFrames[FCount].Region);
end;
Twee details in die schets dragen het meeste gewicht. Dat GetClipRgn 0 teruggeeft is geen fout: het betekent dat de device context op dat moment geen clip had. Bij Q een nulregio aan SelectClipRgn geven is precies wat een clip verwijdert die bij de bijbehorende q niet bestond. 0 als mislukking behandelen laat de binnenclip staan. Het andere detail is de Captured-vlag, die voorkomt dat een tweede clipverandering in hetzelfde frame de vastgelegde regio overschrijft met een al versmalde
Waar de vastgelegde clip en de advance-regel ophouden
De vastgelegde clip is een GDI-regio in devicepixels. Hij is exact voor de bitmap die wordt gerenderd en betekenisloos voor elk ander oppervlak, dus elk frame legt ook de device context vast waar hij vandaan kwam, en het herstel wordt overgeslagen wanneer de context gewisseld heeft, bijvoorbeeld terwijl een transparantiegroep in zijn eigen laagbitmap rendert
Aan de tekstkant plaatst de advance-regel elke glyph op basis van de breedte die hij krijgt. Wanneer een simpel font zijn /Widths-array weglaat en er geen ingesloten fontprogramma beschikbaar is, kan de advance hooguit zo goed zijn als de breedtefallback, en §9.4.4 zegt niets over waar die breedte vandaan komt. Regressietests hiervoor staan op bestanden die simpel gegenereerde documenten bijna nooit bevatten: één met Tf 1 en een geschaalde Tm, één met niet-nul Tz en Tw op uitgevulde tekst, en één met een W n-clip binnen q ... Q gevolgd door content buiten het geclipte gebied. Een correcte render van de derde toont die buitenstaande content volledig
Vanuit applicatiecode heeft geen van dit alles instellingen nodig: de aanroep is degene uit een PDF-pagina naar een bitmap renderen, en beide regels gelden voor elke pagina die je zo rendert. Ondersteunde Delphi- en C++Builder-versies en licentiedetails staan op de pagina van HotPDF Delphi PDF Component