HotPDF livrează THPDFBuiltInOCREngine, un engine OCR bounded cu template matching scris integral în Object Pascal: binarizează o pagină randată prin thresholding Otsu, extrage glifele ca connected components și punctează fiecare glifă prin grayscale coverage față de template-uri multi-font cache-uite, astfel încât o aplicație Delphi poate construi un text layer searchable fără dependență OCR externă. Engine-ul a trebuit reconstruit de la zero în v2.731.0, iar motivul nu a fost matcher-ul. Au fost pixelii
Engine-ul vechi trecea testele. Recunoștea ASCII uppercase pe bitmap-uri sintetice și, pe Win32, continua să facă asta luni întregi. Apoi același cod a fost rulat sub Win64 și nu a mai produs nimic: niciun word, niciun diagnostic în afară de "found no high-contrast foreground", niciun crash. Bug-ul s-a dovedit a fi format din două greșeli independente în ruta de citire a pixelilor, care se anulau reciproc, iar desfacerea lor este o ilustrare bună a motivului pentru care codul OCR eșuează tăcut, nu zgomotos
De ce funcționa vechiul engine OCR doar din întâmplare?
Engine-ul vechi funcționa deoarece bitmap-urile template și bitmap-urile țintă erau răsturnate în același fel, astfel încât inversarea verticală din reader-ul de pixeli era invizibilă pentru matcher. TBitmap.ScanLine returnează rândurile în ordinea opusă convenției DIB cu biHeight pozitiv, pe care o presupune restul traseului de imaging. Randă un M cu susul în jos, compară-l cu un template răsturnat în același fel, iar diferența L1 este identică aceleia din comparația corectă. Fiecare glifă se potrivea. Nimic nu era corect
Simetria aceasta face clasa de bug-uri costisitoare. Orice fix pe o singură parte strică matching-ul: corectează citirea țintei și lasă template-urile neschimbate, iar recognition-ul se prăbușește în zgomot; corectează întâi template-urile și obții aceeași prăbușire din direcția opusă. Nu există o cale de reparare incrementală. Reconstrucția a înlocuit deci citirea completă cu GetDIBits împotriva unui BITMAPINFOHEADER declarat explicit, unde un biHeight pozitiv înseamnă prin contract rânduri bottom-up, nu prin convenția VCL, și face o singură inversare deliberată când copiază în buffer-ul grayscale
A doua greșeală este cea scoasă la iveală doar de Win64. HDC-ul transmis lui GetDIBits nu trebuie să fie propriul memory DC al bitmap-ului, deoarece bitmap-ul este deja selectat în el, iar Windows documentează situația ca invalidă. Transmiterea lui Bitmap.Canvas.Handle era tolerată de procesul Win32 și eșua constant în procesul de test Win64. Remedierea este un screen DC temporar obținut prin GetDC(0), eliberat într-un bloc finally, care nu are nicio legătură cu vreun bitmap
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; // pozitiv => rânduri bottom-up
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // niciodată Work.Canvas.Handle: Work este selectat acolo
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]; // o singură inversare deliberată
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;
Binarizare și connected components: de la pixeli gray la box-uri de glife
HotPDF binarizează mai întâi prin metoda Otsu și revine la un threshold într-o fereastră locală doar când Otsu nu este aplicabil. Ruta globală cere un histogram cu adevărat bimodal: engine-ul calculează maximul varianței între clase și cere suplimentar ca intervalul gray să acopere cel puțin 64 de niveluri înainte să aibă încredere în rezultat. Un scan spălăcit, o pagină cu fundal în gradient sau un bitmap aproape complet umplut cu ink eșuează la acest test. Fallback-ul compară apoi fiecare pixel cu media unei ferestre 31 pe 31 cu bias de 6 niveluri gray, calculată prin running column sums astfel încât fereastra glisantă să rămână liniară în numărul de pixeli
Extragerea glifelor este connected-component labeling 8-connected peste mask-ul rezultat, cu un stack explicit în loc de recursie, deoarece un mask de pagină completă poate umple fără probleme stack-ul unui thread Delphi într-un flood fill adânc. Două filtre rulează în momentul label-ării: componentele mai mici de 9 pixeli sunt eliminate ca speckle noise, iar orice componentă care ocupă mai mult de trei cincimi din lățimea și înălțimea imaginii este eliminată ca frame sau rule, nu ca glifă. Un al doilea pass unește box-uri stivuite vertical al căror overlap orizontal este cel puțin un sfert din box-ul mai îngust, reunind punctul lui i sau j cu tija lui. Toate acestea operează pe un raster, iar rasterul vine din același renderer descris în randarea unei pagini PDF încărcate la bitmap în Delphi, ceea ce contează practic: calitatea OCR are o limită superioară dată de calitatea render-ului, iar DPI-ul implicit de 300 al text layer-ului este un compromis deliberat, nu un maxim
Ce face capital I și lowercase l imposibil de decis?
În Arial, capital I și lowercase l se rasterizează în bare identice pixel cu pixel, așa că nicio caracteristică de formă nu le poate separa, iar case-ul trebuie să vină din altă parte. Răspunsul engine-ului este line-level height clustering. Box-urile glifelor sunt grupate în text lines după overlap vertical, fiecare linie este analizată pentru cap height și baseline-ul modal, iar înălțimile dintr-o linie sunt împărțite într-un cluster scurt și unul înalt. O bară din cluster-ul scurt este un l; aceeași bară în cluster-ul înalt este un I
Implementarea evidentă a împărțirii este un threshold cu ratio fix și nu funcționează. Raportul x-height la cap-height al Arial este aproximativ 0.72, exact peste valorile 0.70 și 0.75 la care ajunge toată lumea prima dată. Mută constanta cu o sutime într-o direcție sau alta și un corpus întreg își schimbă case-ul. HotPDF face în schimb o împărțire unidimensională k=2 care minimizează varianța: sortează înălțimile candidate, încearcă fiecare punct de tăiere și păstrează tăietura a cărei sumă a deviațiilor pătratice din interiorul clusterelor este minimă. Threshold-ul devine o proprietate a paginii, nu o constantă din source
// ClusterHeights este sortat crescător; găsește split-ul k=2 cu varianța minimă
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;
// numai raportul dintre mediile celor două clustere decide banda scurtă
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // o bandă x-height reală: forme lowercase
else
SmallGroup := ggTall; // o singură bandă de înălțime: totul este cap height
Line.LowercaseContext := (SmallGroup = ggSmall);
Liniile cu o singură bandă de înălțime nu poartă nicio dovadă internă. Un heading cu toate caracterele uppercase și un caption cu toate lowercase arată la fel izolat. Pentru acestea, HotPDF compară înălțimea mediană a liniei cu x-height-ul median la nivel de pagină, luat din liniile care s-au împărțit: un ratio cel mult 1.10 marchează linia ca lowercase context, un ratio cel puțin 1.18 o marchează ca cap context, iar tot ce se află între ele rămâne fără constrângere. Matching-ul aplică apoi un mic bonus de case preference de 0.03 către candidatul care corespunde contextului, ceea ce înclină egalitățile fără să suprascrie vreodată o diferență clară de formă
De ce confunda o grilă de template 12x18 literele c și o?
Grila de template a fost extinsă de la 12 pe 18 celule la 16 pe 24, deoarece la rezoluția mai mică marja de grayscale coverage dintre c și o cobora sub 0.007, mult în interiorul pragului de ambiguitate al engine-ului. Fiecare box de glifă este resampled în grilă ca valori de coverage de la 0 la 255, nu ca stencil binar, așa că o celulă cu o treime ink citește aproximativ 85 în loc să fie rotunjită la negru sau alb. La 12 pe 18, partea deschisă a unui c abia depășește o coloană de celule, iar media antialiased spală golul. La 16 pe 24, golul supraviețuiește resampling-ului, iar majoritatea perechilor ușor de confundat revin la o distanță sigură
Scoring-ul este distanța L1 normalizată dintre cele două coverage grids, plus o penalizare de 0.30 ori diferența logaritmului de aspect ratio și 0.16 ori diferența densității de ink, cu un prefilter hard care sare peste orice template al cărui aspect ratio diferă cu mai mult de un factor 2.6. Template-urile sunt rasterizate o dată per process din cinci system fonts (Arial, Times New Roman, Courier New, Tahoma și Segoe UI) pentru un alfabet de 62 de caractere, cache-uite în spatele unei critical section și refolosite la fiecare apel ulterior
Ultima constantă este cea interesantă. Când caracterul de pe locul doi ajunge la cel mult 0.018 de câștigător, HotPDF limitează confidence-ul glifei la 0.5, sub acceptance gate-ul de 0.55, așa că glifa nu este emisă deloc. Este o tăietură fail-closed deliberată, nu un artifact de tuning: un engine bounded care ghicește produce un layer searchable al cărui text nu corespunde imaginii, iar un cuvânt greșit într-un text layer este mai rău decât unul lipsă, pentru că rămâne invizibil persoanei care verifică scanarea
Separarea cuvintelor fără un prag fix pentru gap
HotPDF derivă threshold-ul pentru spațiul dintre cuvinte per linie din distribuția inter-glyph gaps, nu dintr-un multiplu fix al lățimii medii a glifei. Heuristica clasică, „un gap mai lat decât 0.75 din advance-ul mediu este un spațiu”, se rupe imediat ce o linie amestecă cifre cu litere înguste, deoarece advance-ul mediu nu mai descrie nimic real. Engine-ul sortează gap-urile pentru linie și caută cea mai mare săritură între valori consecutive sortate, care este limita dintre cluster-ul intra-word și cluster-ul inter-word dacă există. Trei guard-uri împiedică activarea pe noise: saltul trebuie să fie cel puțin 0.22 din lățimea medie a glifei, primul gap deasupra split-ului trebuie să fie cel puțin 0.32 din aceasta, iar ultimul gap de sub split nu trebuie să depășească 0.65 din aceasta. Dacă orice guard eșuează, threshold-ul rămâne MaxInt și întreaga linie devine un singur word. Ultimul guard previne ca o singură pereche de kerning neobișnuit de lată să împartă un cuvânt în două, o eroare mult mai dăunătoare decât unirea a două cuvinte, deoarece un token unit conține încă aceleași caractere în aceeași ordine pentru un substring search
Scrierea text layer-ului invizibil peste imaginea scanată
ApplyLoadedOCRTextLayer transformă words recunoscute într-un layer searchable desenându-le în text rendering mode 3, modul neither-fill-nor-stroke definit în ISO 32000-1 §9.3.6, poziționat peste imaginea scanată din care provin. Content stream-ul începe cu BT urmat de 3 Tr, iar fiecare word este așezat cu o text matrix construită din baseline-ul raportat, cap height-ul convertit din pixeli la DPI-ul cerut și un horizontal scale care întinde synthetic glyph run-ul la lățimea măsurată a word-ului. Rezultatul se copiază și se caută precum textul și nu pictează nimic
Există un overload fără engine care instanțiază built-in recognizer-ul pentru tine și acesta este traseul pe care ar trebui să îl folosească majoritatea caller-ilor built-in. Recognition, validarea Unicode, contabilizarea budget-ului și construcția content-ului se termină înainte să se deschidă tranzacția copy-on-write, astfel încât o cancelare, depășirea unui budget sau un engine failure lasă object graph-ul și version number neschimbate. Words sunt filtrate de două ori: engine-ul elimină orice glifă sub propriul gate de confidence 0.55, apoi THPDFOCRTextLayerOptions.MinimumConfidence (default 0.5) elimină words întregi sub pragul caller-ului
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; // lasă paginile born-digital neschimbate
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload fără engine: HotPDF furnizează recognizer-ul bounded inclus
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;
O limită trebuie spusă direct, nu descoperită mai târziu. Layer-ul invizibil folosește un font Type0 synthetic shared și ne-embedded, suficient pentru search și copy în orice viewer, dar care nu îndeplinește cerința de font embedding din ISO 19005. Dacă output-ul trebuie să fie PDF/A, caller-ul trebuie să embed-uiască separat un font conform. Și un OCR text layer poartă geometrie, nu structură, așa că reading order-ul vine doar din pozițiile glifelor; dacă ai nevoie de ordine logică dintr-o pagină care are deja text real, extragerea textului în ordinea structurii, condusă de tag tree este un alt tool pentru o altă problemă
Unde se oprește engine-ul inclus
Engine-ul inclus este deliberat îngust, iar cunoașterea limitelor lui este ceea ce îl menține util. Țintește ASCII machine-printed cu contrast ridicat, din fonturi apropiate de cele cinci template faces, iar orice în afara acestui envelope returnează no word în loc să ghicească. Limitele concrete sunt:
- Imagini de până la 4096 pe 4096 și 4.194.304 pixeli, cu deadline de recognition 2000 ms și cancellation cooperativă prin
THPDFCancellationToken - Un alfabet de 62 de caractere format din litere și cifre ASCII; fără punctuation, fără caractere accentuate, fără CJK
- Text doar axis-aligned, la rotația paginii pe care renderer-ul a normalizat-o deja; scanările skewed nu sunt deskewed
- Perechile de glife ambigue rămân nerezolvate, așa că o pagină poate returna words parțiale sau diagnosticul "found no unambiguous ASCII words"
Când envelope-ul este prea mic, IHPDFOCREngine este seam-ul. Implementează Recognize pentru engine-ul tău, transmite-l overload-ului ApplyLoadedOCRTextLayer cu trei argumente, iar tot ce urmează — mapping de coordonate, tratarea rotației, validarea Unicode, bugete și commit atomic — rămâne la fel. Bitmap-ul este împrumutat pe durata apelului sincron și nu trebuie păstrat. Pentru a confirma că layer-ul a ajuns corect, reîncarcă fișierul salvat și rulează ruta obișnuită de text descrisă în extragerea textului dintr-un PDF încărcat în Delphi; dacă words revin, layer-ul este real
OCR-ul built-in cu template matching, text layer-ul invizibil, renderer-ul de pagini care le alimentează și text extraction-ul din documentul încărcat care le verifică sunt livrate în același component VCL nativ, fără runtime OCR extern și fără DLL de deployat alături de aplicație. Dacă construiești în Delphi sau C++Builder document capture, arhivare sau search peste PDF-uri scanate, componenta PDF HotPDF pentru Delphi îți oferă întregul pipeline într-o singură dependență