HotPDF levert THPDFBuiltInOCREngine, een begrensde template-matching-OCR-engine die volledig in Object Pascal is geschreven: hij binariseert een gerenderde pagina met Otsu-thresholding, extraheert glyphs als connected components en scoort elke glyph op basis van grijsdekking tegen gecachte templates voor meerdere fonts, zodat een Delphi-applicatie zonder externe OCR-dependency een doorzoekbare tekstlaag kan bouwen. De engine moest in v2.731.0 vanaf nul worden herbouwd, en de reden was niet de matcher. Het waren de pixels
De oude engine slaagde voor zijn tests. Hij herkende uppercase ASCII op synthetische bitmaps en op Win32 bleef hij dat maanden doen. Daarna werd dezelfde code onder Win64 uitgevoerd en produceerde hij helemaal niets: geen woorden, geen diagnose behalve "found no high-contrast foreground" en geen crash. De bug bleek uit twee onafhankelijke fouten in het pixel-leespad te bestaan die elkaar hadden opgeheven, en het uit elkaar halen daarvan laat goed zien waarom OCR-code stil faalt in plaats van luid
Waarom werkte de oude OCR-engine alleen bij toeval?
De oude engine werkte omdat zijn template-bitmaps en targetbitmaps op dezelfde manier waren omgedraaid, zodat een verticale inversie in de pixelreader onzichtbaar was voor de matcher. TBitmap.ScanLine geeft rijen terug in de omgekeerde volgorde van de positive-biHeight-DIB-conventie waarvan de rest van het imagingpad uitgaat. Render je een M ondersteboven, vergelijk je die met een eveneens ondersteboven template, dan is het L1-verschil identiek aan de correcte vergelijking. Elke glyph matchte. Niets klopte
Die symmetrie maakt dit soort bugs precies zo duur. Elke eenzijdige fix breekt de matching: corrigeer je het uitlezen van de target en laat je de templates staan, dan valt herkenning terug tot ruis; corrigeer je eerst de templates, dan krijg je dezelfde instorting vanuit de andere richting. Er bestaat geen incrementeel reparatiepad. De rebuild verving daarom het volledige read-pad door GetDIBits tegen een expliciet gedeclareerde BITMAPINFOHEADER, waarbij een positieve biHeight volgens contract bottom-up-rijen betekent in plaats van volgens VCL-conventie, en draait het beeld één keer bewust om bij het kopiëren naar de grayscale-buffer
De tweede fout kwam pas onder Win64 aan het licht. De HDC die aan GetDIBits wordt doorgegeven mag niet de eigen memory DC van de bitmap zijn, omdat de bitmap daar al in geselecteerd is en Windows dat als ongeldig documenteert. Bitmap.Canvas.Handle doorgeven werd door het Win32-proces getolereerd en faalde consequent in het Win64-testproces. De fix is een wegwerp-screen-DC uit GetDC(0), vrijgegeven in een finally-blok, die niets met een bitmap te maken heeft
procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
Work: TBitmap;
Info: TBitmapInfo;
Buffer: TBytes;
DC: HDC;
P: PByte;
Stride, X, Y: Integer;
begin
Work := TBitmap.Create;
try
Work.Assign(Bitmap);
Work.PixelFormat := pf24bit;
Stride := ((Work.Width * 24 + 31) div 32) * 4;
SetLength(Buffer, Stride * Work.Height);
FillChar(Info, SizeOf(Info), 0);
Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
Info.bmiHeader.biWidth := Work.Width;
Info.bmiHeader.biHeight := Work.Height; // positief => bottom-up-rijen
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // nooit Work.Canvas.Handle: Work is daar geselecteerd
if DC = 0 then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
try
if GetDIBits(DC, Work.Handle, 0, Work.Height,
@Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
finally
ReleaseDC(0, DC);
end;
SetLength(Gray, Work.Width * Work.Height);
for Y := 0 to Work.Height - 1 do
begin
P := @Buffer[(Work.Height - 1 - Y) * Stride]; // één bewuste flip
for X := 0 to Work.Width - 1 do
Gray[Y * Work.Width + X] :=
(Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
Integer(P[X * 3 + 2]) * 77) shr 8;
end;
finally
Work.Free;
end;
end;
Binarisatie en connected components: van grijze pixels naar glyphvakken
HotPDF binariseert eerst met de methode van Otsu en valt alleen terug op een threshold met een lokaal window wanneer Otsu niet toepasbaar is. Het globale pad vereist een echt bimodaal histogram: de engine berekent het maximum van de between-class variance en eist bovendien dat het grijsbereik ten minste 64 niveaus beslaat voordat hij het resultaat vertrouwt. Een verbleekte scan, een pagina met een gradientachtergrond of een bitmap die bijna volledig uit inkt bestaat, faalt allemaal voor die test. De fallback vergelijkt elke pixel daarna met het gemiddelde van een window van 31 bij 31 met een bias van 6 grijsniveaus, berekend met lopende kolomsommen zodat het sliding window lineair blijft in het aantal pixels
Glyphextractie is 8-connected component labeling over het resulterende mask, met een expliciete stack in plaats van recursie, omdat een mask over een volledige pagina een Delphi-threadstack moeiteloos kan opblazen tijdens een diepe flood fill. Bij het labelen draaien twee filters: componenten kleiner dan 9 pixels worden als speckle noise verwijderd en elke component die meer dan drie vijfde van zowel de breedte als de hoogte van de afbeelding beslaat, wordt als frame of regel verwijderd in plaats van als glyph. Een tweede pass voegt verticaal gestapelde vakken samen waarvan de horizontale overlap minstens een kwart van het smallere vak is, waardoor de punt van een i of j weer met de steel wordt verenigd. Dit alles werkt op een raster, en dat raster komt uit dezelfde renderer die wordt beschreven in een geladen PDF-pagina naar een bitmap renderen in Delphi. Dat is praktisch van belang: OCR-kwaliteit wordt van boven begrensd door renderkwaliteit, en de standaard-DPI van 300 voor de tekstlaag is een bewuste trade-off en geen maximum
Wat maakt hoofdletter I en kleine letter l onbeslisbaar?
In Arial rasteriseren hoofdletter I en kleine letter l tot pixelidentieke balken, dus geen enkele shape feature kan ze uit elkaar houden en de hoofdletterstatus moet volledig ergens anders vandaan komen. Het antwoord van de engine is height clustering op regelniveau. Glyphvakken worden op basis van verticale overlap in tekstregels gegroepeerd, elke regel wordt geanalyseerd op zijn cap height en modale baseline en de hoogtes binnen een regel worden gesplitst in een korte en een hoge cluster. Een balk in de korte cluster is een l; dezelfde balk in de hoge cluster is een I
De voor de hand liggende implementatie van die splitsing is een vaste ratiodrempel, en die werkt niet. De x-height/cap-height-ratio van Arial is ongeveer 0,72, precies tussen de waarden 0,70 en 0,75 waarop iedereen als eerste uitkomt. Verplaats de constante met een honderdste in welke richting dan ook en een heel corpus wisselt van hoofdletterstatus. HotPDF doet in plaats daarvan een eendimensionale variantie-minimaliserende k=2-splitsing: sorteer de kandidaatshoogtes, probeer elk snijpunt en behoud de cut waarvan de som van gekwadrateerde afwijkingen binnen de clusters het kleinst is. De drempel wordt zo een eigenschap van de pagina in plaats van een constante in de broncode
// ClusterHeights staat oplopend gesorteerd; zoek de k=2-splitsing met de kleinste variantie
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
SumA := 0;
for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
SumB := 0;
for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
MeanA := SumA / I;
MeanB := SumB / (ClusterCount - I);
Variance := 0;
for J := 0 to I - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
for J := I to ClusterCount - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
if Variance < BestVariance then
begin
BestVariance := Variance;
BestSplit := I;
end;
end;
// alleen de verhouding tussen de twee clustergemiddelden bepaalt de korte band
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // echte x-height-band: vormen van kleine letters
else
SmallGroup := ggTall; // één hoogteband: alles heeft cap height
Line.LowercaseContext := (SmallGroup = ggSmall);
Regels met één enkele hoogteband bevatten intern helemaal geen bewijs. Een heading in alleen hoofdletters en een caption in alleen kleine letters zien er los van de context hetzelfde uit. Voor die regels vergelijkt HotPDF de mediane hoogte van de regel met de mediane x-height op paginaniveau, afkomstig van regels die wel zijn gesplitst: een ratio van maximaal 1,10 markeert de regel als lowercase context, een ratio van minimaal 1,18 als cap context en alles ertussen blijft onbeperkt. Bij de matching wordt vervolgens een kleine case-preferencebonus van 0,03 toegepast op de kandidaat die met die context overeenkomt, waardoor ties een zetje krijgen zonder ooit een duidelijk vormverschil te overrulen
Waarom bracht een templategrid van 12x18 c en o door elkaar?
Het templategrid is verbreed van 12 bij 18 cellen naar 16 bij 24, omdat bij de kleinere resolutie de marge in grijsdekking tussen c en o onder 0,007 zakte, ruim binnen de ambiguïteitsdrempel van de engine. Elk glyphvak wordt in het grid geresampled als dekkingwaarden van 0 tot 255 en niet als een binaire stencil, dus een cel die voor een derde uit inkt bestaat leest ongeveer als 85 in plaats van naar zwart of wit af te ronden. Bij 12 bij 18 beslaat de open kant van een c nauwelijks meer dan één celkolom en wast het antialiased gemiddelde de opening weg. Bij 16 bij 24 blijft de opening na het resamplen bestaan en komen de meeste gemakkelijk te verwarren paren weer op veilige afstand
Scoring is de genormaliseerde L1-afstand tussen de twee coveragegrids, plus een penalty van 0,30 maal het verschil in log aspect ratio en 0,16 maal het verschil in inktdichtheid, met een harde prefilter die elk template overslaat waarvan de aspect ratio meer dan een factor 2,6 verschilt. Templates worden eenmaal per proces gerasteriseerd vanuit vijf systeemfonts (Arial, Times New Roman, Courier New, Tahoma en Segoe UI) over een alfabet van 62 tekens, achter een critical section gecachet en door elke volgende call hergebruikt
De laatste constante is de interessante. Wanneer de score van het runner-up-teken binnen 0,018 van de winnaar ligt, clamped HotPDF het confidence-niveau van de glyph op 0,5, onder de acceptance gate van 0,55, zodat de glyph eenvoudigweg niet wordt uitgegeven. Dat is een bewuste fail-closed grens en geen tuningartefact: een begrensde engine die gokt produceert een doorzoekbare laag waarvan de tekst niet bij de afbeelding past, en een verkeerd woord in een tekstlaag is erger dan een ontbrekend woord omdat het onzichtbaar is voor degene die de scan beoordeelt
Woorden splitsen zonder vaste gapdrempel
HotPDF leidt de word-space-drempel per regel af uit de verdeling van de inter-glyph-gaps in plaats van uit een vaste veelvoud van de gemiddelde glyphbreedte. De klassieke heuristiek, "een gap breder dan 0.75 van de gemiddelde advance is een spatie", breekt zodra een regel cijfers met smalle letters mengt, omdat de gemiddelde advance dan niets echts meer beschrijft. De engine sorteert de gaps van de regel en zoekt de grootste sprong tussen opeenvolgende gesorteerde waarden. Dat is de grens tussen de intra-word-cluster en de inter-word-cluster als die bestaat. Drie guards voorkomen dat dit op ruis afgaat: de sprong moet ten minste 0,22 van de gemiddelde glyphbreedte zijn, de eerste gap boven de split moet ten minste 0,32 daarvan zijn en de laatste gap onder de split mag niet groter zijn dan 0,65 daarvan. Faalt een guard, dan blijft de drempel op MaxInt staan en wordt de hele regel één woord. Die laatste guard voorkomt dat één uitzonderlijk brede kerning pair een woord in tweeën splitst, een veel schadelijkere fout dan twee woorden samenvoegen, want een samengevoegd token bevat nog steeds de juiste tekens in de juiste volgorde voor een substring search
De onzichtbare tekstlaag over de gescande afbeelding schrijven
ApplyLoadedOCRTextLayer zet herkende woorden om in een doorzoekbare laag door ze te tekenen in text rendering mode 3, de neither-fill-nor-stroke-modus uit ISO 32000-1 §9.3.6, gepositioneerd over de gescande afbeelding waaruit ze afkomstig zijn. De contentstream opent met BT, gevolgd door 3 Tr, en elk woord wordt geplaatst met een text matrix die is opgebouwd uit de gemelde baseline, de cap height omgerekend van pixels bij de gevraagde DPI en een horizontale schaal die de synthetische glyphrun uitrekt tot de gemeten woordbreedte. Het resultaat kan als tekst worden gekopieerd en doorzocht, maar schildert niets
Er is een engine-free overload die de ingebouwde recognizer voor je instantieert, en die zouden de meeste callers van het built-in pad moeten gebruiken. Recognition, Unicode-validatie, budgetboekhouding en contentconstructie zijn allemaal voltooid voordat de copy-on-write-transactie opent, zodat een cancellation, budgetoverschrijding of enginefout de objectgraph en het versienummer onaangeroerd laat. Woorden worden tweemaal gefilterd: de engine verwijdert alles onder zijn eigen confidence gate van 0,55 per glyph, daarna verwijdert THPDFOCRTextLayerOptions.MinimumConfidence (standaard 0,5) hele woorden onder de grens van de caller
var
Doc: THotPDF;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
Doc := THotPDF.Create(nil);
try
Doc.AutoLaunch := False;
if Doc.LoadFromFile('scan.pdf') < 1 then
Exit;
Options := THPDFOCRTextLayerOptions.Default; // DPI 300, MinimumConfidence 0.5
Options.SkipPagesWithText := True; // born-digital pagina's ongemoeid laten
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// engine-free overload: HotPDF levert de ingebouwde begrensde recognizer
if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
begin
Writeln(Info.AcceptedWordCount, ' words accepted by ',
string(Info.EngineName));
Doc.SaveLoadedDocument('scan-searchable.pdf');
end
else
Writeln('No text layer written: ', string(Info.Diagnostic));
finally
Doc.Free;
end;
end;
Eén beperking moet je duidelijk noemen in plaats van die later te ontdekken. De onzichtbare laag gebruikt een gedeeld synthetisch niet-embedded Type0-font, wat in elke viewer voldoende is voor zoeken en kopiëren maar niet voldoet aan de font-embedding-eis van ISO 19005. Als de uitvoer PDF/A moet zijn, moet de caller afzonderlijk een conform font embedden. En een OCR-tekstlaag draagt geometrie en geen structuur, dus de leesvolgorde komt alleen uit glyphposities; als je een logische volgorde nodig hebt van een pagina die al echte tekst bevat, is structure-order text extraction op basis van de tag tree een ander hulpmiddel voor een ander probleem
Waar stopt de ingebouwde engine?
De ingebouwde engine is bewust smal, en weten waar de randen liggen houdt hem bruikbaar. Hij richt zich op high-contrast machine-printed ASCII uit fonts die dicht bij zijn vijf templatefaces liggen, en alles daarbuiten retourneert geen woord in plaats van een gok. De concrete grenzen zijn:
- Afbeeldingen tot 4096 bij 4096 en 4.194.304 pixels, met een herkenningsdeadline van 2000 ms en coöperatieve annulering via
THPDFCancellationToken - Een alfabet van 62 ASCII-letters en -cijfers; geen leestekens, geen geaccentueerde tekens en geen CJK
- Alleen axis-aligned tekst, bij de paginarotatie die de renderer al heeft genormaliseerd; scheef gescande pagina's worden niet deskewed
- Ambigue glyphparen blijven onopgelost, zodat een pagina gedeeltelijke woorden of de diagnose "found no unambiguous ASCII words" kan teruggeven
Wanneer die envelop te klein is, is IHPDFOCREngine de seam. Implementeer Recognize tegen je eigen engine, geef die aan de overload met drie argumenten van ApplyLoadedOCRTextLayer en al het downstreamwerk (coördinaatmapping, rotatieafhandeling, Unicode-validatie, budgetten en de atomic commit) blijft hetzelfde. De bitmap wordt geleend voor de duur van de synchrone call en mag niet worden bewaard. Om te bevestigen dat de laag correct is geland, laad je het opgeslagen bestand opnieuw en voer je het gewone tekstpad uit dat wordt beschreven in tekst extraheren uit een geladen PDF in Delphi; als de woorden terugkomen, is de laag echt
De ingebouwde template-matching-OCR, de onzichtbare tekstlaag, de pagerenderer die ze voedt en de tekstextractie van het geladen document die ze controleert, worden allemaal meegeleverd in dezelfde native VCL-component, zonder externe OCR-runtime en zonder DLL die naast je applicatie moet worden uitgerold. Als je document capture, archivering of zoeken in gescande PDF's bouwt in Delphi of C++Builder, geeft de HotPDF Delphi PDF-component je de volledige pipeline als één dependency