Το HotPDF παρέχει το THPDFBuiltInOCREngine, έναν bounded template-matching OCR engine γραμμένο εξ ολοκλήρου σε Object Pascal: κάνει binarize μια rendered page με Otsu thresholding, εξάγει τα glyphs ως connected components και βαθμολογεί κάθε glyph με grayscale coverage απέναντι σε cached multi-font templates, ώστε μια Delphi application να μπορεί να δημιουργήσει searchable text layer χωρίς external OCR dependency. Ο engine χρειάστηκε να ξαναχτιστεί από την αρχή στο v2.731.0, και ο λόγος δεν ήταν ο matcher. Ήταν τα pixels
Ο παλιός engine περνούσε τα tests του. Αναγνώριζε uppercase ASCII σε synthetic bitmaps, και στο Win32 συνέχιζε να το κάνει για μήνες. Ύστερα ο ίδιος code έτρεξε σε Win64 και δεν παρήγαγε απολύτως τίποτα: ούτε words, ούτε diagnostic πέρα από το «found no high-contrast foreground», ούτε crash. Το bug αποδείχθηκε ότι ήταν δύο ανεξάρτητα λάθη στη διαδρομή ανάγνωσης pixels που αλληλοαναιρούνταν, και το ξεμπέρδεμά τους δείχνει καλά γιατί ο OCR code αποτυγχάνει σιωπηλά αντί να αποτυγχάνει θεαματικά
Γιατί ο παλιός OCR engine λειτουργούσε μόνο από τύχη
Ο παλιός engine λειτουργούσε επειδή τα template bitmaps και τα target bitmaps ήταν flipped με τον ίδιο τρόπο, οπότε μια κατακόρυφη αναστροφή στον pixel reader ήταν αόρατη στον matcher. Το TBitmap.ScanLine επιστρέφει rows με αντίθετη σειρά από τη positive-biHeight DIB convention που υποθέτει η υπόλοιπη imaging path. Κάνε render ένα M ανάποδα, σύγκρινέ το με template που είναι επίσης ανάποδα, και η L1 διαφορά είναι ίδια με τη σωστή σύγκριση. Κάθε glyph έκανε match. Τίποτε δεν ήταν σωστό
Αυτή η συμμετρία είναι ακριβώς εκείνη που κάνει ακριβή αυτή την κατηγορία bug. Κάθε μονόπλευρη διόρθωση χαλά το matching: διόρθωσε την ανάγνωση του target και άφησε τα templates ως έχουν, και το recognition καταρρέει σε noise· διόρθωσε πρώτα τα templates και παίρνεις την ίδια κατάρρευση από την άλλη κατεύθυνση. Δεν υπάρχει incremental repair path. Γι’ αυτό το rebuild αντικατέστησε ολόκληρη την ανάγνωση με GetDIBits απέναντι σε ρητά δηλωμένο BITMAPINFOHEADER, όπου positive biHeight σημαίνει bottom-up rows κατά contract και όχι κατά VCL convention, και κάνει μία σκόπιμη αναστροφή όταν αντιγράφει στο grayscale buffer
Το δεύτερο λάθος εμφανίστηκε μόνο στο Win64. Το HDC που περνά στο GetDIBits δεν πρέπει να είναι το ίδιο το memory DC του bitmap, επειδή το bitmap είναι ήδη selected σε αυτό και τα Windows το τεκμηριώνουν ως invalid. Το να περάσεις Bitmap.Canvas.Handle γινόταν ανεκτό από το Win32 process και αποτύγχανε σταθερά στο Win64 test process. Η διόρθωση είναι ένα προσωρινό screen DC από το GetDC(0), που απελευθερώνεται σε finally block και δεν έχει καμία σχέση με 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; // positive => bottom-up rows
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // ποτέ Work.Canvas.Handle: το Work είναι selected εκεί
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]; // μία σκόπιμη αναστροφή
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;
Binarization και connected components: από gray pixels σε glyph boxes
Το HotPDF κάνει πρώτα binarize με τη μέθοδο του Otsu και καταφεύγει σε threshold με local window μόνο όταν δεν εφαρμόζεται ο Otsu. Η global path απαιτεί πραγματικά bimodal histogram: ο engine υπολογίζει το maximum της between-class variance και επιπλέον απαιτεί το gray range να καλύπτει τουλάχιστον 64 levels πριν εμπιστευτεί το αποτέλεσμα. Ένα washed-out scan, μια σελίδα με gradient background ή ένα bitmap σχεδόν εξ ολοκλήρου από ink αποτυγχάνουν σε αυτό το test. Ο fallback συγκρίνει τότε κάθε pixel με τον μέσο όρο ενός window 31 επί 31 με bias 6 gray levels, υπολογισμένο με running column sums ώστε το sliding window να παραμένει γραμμικό ως προς τον αριθμό των pixels
Η εξαγωγή glyphs είναι 8-connected component labeling πάνω στο mask που προέκυψε, με explicit stack αντί για recursion, επειδή ένα full-page mask μπορεί εύκολα να εξαντλήσει το Delphi thread stack σε deep flood fill. Δύο filters τρέχουν κατά το labeling: components μικρότερα από 9 pixels απορρίπτονται ως speckle noise, και κάθε component που εκτείνεται σε περισσότερο από τα τρία πέμπτα τόσο του width όσο και του height της εικόνας απορρίπτεται ως frame ή rule και όχι ως glyph. Ένα δεύτερο pass ενώνει vertically stacked boxes των οποίων το horizontal overlap είναι τουλάχιστον ένα τέταρτο του στενότερου box, και έτσι επανενώνει την τελεία του i ή του j με το stem του. Όλα αυτά λειτουργούν σε raster, και το raster προέρχεται από τον ίδιο renderer που περιγράφεται στο rendering loaded PDF page σε bitmap στο Delphi, κάτι που έχει πρακτική σημασία: η ποιότητα του OCR δεν μπορεί να ξεπεράσει την ποιότητα του render και το default text-layer DPI των 300 είναι σκόπιμο trade-off και όχι ανώτατο όριο
Τι κάνει το κεφαλαίο I και το πεζό l αδύνατα να διακριθούν
Στην Arial, το κεφαλαίο I και το πεζό l rasterize σε pixels πανομοιότυπα, οπότε κανένα shape feature δεν μπορεί να τα ξεχωρίσει και το case πρέπει να έρθει από εντελώς αλλού. Η απάντηση του engine είναι line-level height clustering. Τα glyph boxes ομαδοποιούνται σε text lines με βάση το vertical overlap, κάθε line αναλύεται για το cap height και το modal baseline της, και τα heights μέσα σε μια line χωρίζονται σε short cluster και tall cluster. Ένα bar στο short cluster είναι l· το ίδιο bar στο tall cluster είναι I
Η προφανής υλοποίηση αυτού του split είναι ένα fixed ratio threshold, και δεν λειτουργεί. Ο λόγος x-height προς cap-height της Arial είναι περίπου 0.72, δηλαδή πέφτει ακριβώς πάνω στις τιμές 0.70 και 0.75 που δοκιμάζουν πρώτες οι περισσότεροι. Μετακίνησε τη σταθερά κατά ένα εκατοστό προς οποιαδήποτε κατεύθυνση και ολόκληρο corpus αλλάζει case. Το HotPDF κάνει αντί γι’ αυτό one-dimensional k=2 variance-minimizing split: ταξινομεί τα candidate heights, δοκιμάζει κάθε cut point και κρατά το cut με το μικρότερο within-cluster sum of squared deviations. Το threshold γίνεται ιδιότητα της σελίδας και όχι constant στον source
// Το ClusterHeights είναι sorted ascending· βρες το k=2 split με τη μικρότερη variance
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;
// μόνο ο λόγος ανάμεσα στους δύο cluster means αποφασίζει ποιο band είναι short
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // πραγματικό x-height band: lowercase shapes
else
SmallGroup := ggTall; // ένα height band: όλα είναι cap height
Line.LowercaseContext := (SmallGroup = ggSmall);
Οι lines με ένα μόνο height band δεν φέρουν εσωτερικό evidence. Ένα all-caps heading και ένα all-lowercase caption φαίνονται ίδια όταν τα δεις μόνα τους. Σε αυτές, το HotPDF συγκρίνει το median height της line με το page-level median x-height που πήρε από lines οι οποίες πράγματι χωρίστηκαν: ratio έως και 1.10 χαρακτηρίζει τη line ως lowercase context, ratio από 1.18 και πάνω ως cap context, και οτιδήποτε ανάμεσα μένει unconstrained. Το matching προσθέτει έπειτα ένα μικρό case-preference bonus 0.03 προς τον candidate που συμφωνεί με αυτό το context, μετακινώντας τα ties χωρίς να παρακάμπτει ποτέ καθαρή διαφορά σχήματος
Γιατί ένα template grid 12x18 μπέρδευε τα c και o
Το template grid μεγάλωσε από 12 επί 18 cells σε 16 επί 24 επειδή στη μικρότερη ανάλυση το grayscale coverage margin ανάμεσα στα c και o έπεφτε κάτω από 0.007, πολύ μέσα στο ambiguity threshold του engine. Κάθε glyph box γίνεται resample στο grid ως coverage values από 0 έως 255 και όχι ως binary stencil, οπότε ένα cell με ένα τρίτο ink διαβάζεται περίπου ως 85 αντί να στρογγυλοποιείται σε black ή white. Στο 12 επί 18 η ανοιχτή πλευρά ενός c καλύπτει μόλις λίγο περισσότερο από μία cell column και το antialiased average εξαφανίζει το κενό. Στο 16 επί 24 το κενό επιβιώνει στο resampling και τα περισσότερα εύκολα μπερδεμένα ζευγάρια επιστρέφουν σε ασφαλή απόσταση
Το scoring είναι normalized L1 distance ανάμεσα στα δύο coverage grids, συν penalty 0.30 επί τη log aspect-ratio difference και 0.16 επί τη ink-density difference, με hard prefilter που παραλείπει template όταν το aspect ratio του διαφέρει πάνω από factor 2.6. Τα templates rasterize μία φορά ανά process από πέντε system fonts (Arial, Times New Roman, Courier New, Tahoma και Segoe UI) πάνω σε alphabet 62 χαρακτήρων, αποθηκεύονται πίσω από critical section και επαναχρησιμοποιούνται από κάθε επόμενο call
Η τελευταία constant είναι η ενδιαφέρουσα. Όταν ο runner-up character βαθμολογείται εντός 0.018 από τον winner, το HotPDF περιορίζει την confidence του glyph στο 0.5, που βρίσκεται κάτω από το acceptance gate 0.55, οπότε το glyph απλώς δεν εκπέμπεται. Είναι σκόπιμο fail-closed cut και όχι tuning artifact: ένας bounded engine που μαντεύει παράγει searchable layer του οποίου το text δεν ταιριάζει με την εικόνα, και μια λάθος λέξη σε text layer είναι χειρότερη από μια λέξη που λείπει επειδή μένει αόρατη σε αυτόν που ελέγχει το scan
Διαχωρισμός λέξεων χωρίς fixed gap threshold
Το HotPDF παράγει το word-space threshold ανά line από την κατανομή των inter-glyph gaps και όχι από fixed πολλαπλάσιο του average glyph width. Η κλασική heuristic, «gap πλατύτερο από 0.75 του mean advance σημαίνει space», σπάει μόλις μια line αναμειγνύει digits με narrow letters, επειδή το mean advance παύει να περιγράφει κάτι πραγματικό. Ο engine ταξινομεί αντί γι’ αυτό τα gaps της line και ψάχνει το μεγαλύτερο jump ανάμεσα σε διαδοχικές sorted values, που είναι το boundary ανάμεσα στο intra-word cluster και στο inter-word cluster όταν υπάρχει. Τρεις guards αποτρέπουν την ενεργοποίηση από noise: το jump πρέπει να είναι τουλάχιστον 0.22 του average glyph width, το πρώτο gap πάνω από το split τουλάχιστον 0.32 αυτού και το τελευταίο gap κάτω από το split όχι πάνω από 0.65 αυτού. Αν αποτύχει οποιοσδήποτε guard, το threshold μένει MaxInt και ολόκληρη η line γίνεται μία λέξη. Αυτός ο τελευταίος guard εμποδίζει ένα ασυνήθιστα πλατύ kerning pair να χωρίσει μία λέξη στα δύο, κάτι πολύ πιο ζημιογόνο από το να ενωθούν δύο λέξεις, αφού ένα merged token κρατά τους σωστούς χαρακτήρες με τη σωστή σειρά για substring search
Εγγραφή του αόρατου text layer πάνω από τη scanned image
Το ApplyLoadedOCRTextLayer μετατρέπει τις recognized words σε searchable layer σχεδιάζοντάς τες με text rendering mode 3, το neither-fill-nor-stroke mode που ορίζει το ISO 32000-1 §9.3.6, τοποθετημένες πάνω από τη scanned image από την οποία προήλθαν. Το content stream ανοίγει με BT και ακολουθείται από 3 Tr, ενώ κάθε word τοποθετείται με text matrix που χτίζεται από το reported baseline, το cap height της μετατρεμμένο από pixels στο request DPI και horizontal scale που τεντώνει το synthetic glyph run στο measured word width. Το αποτέλεσμα αντιγράφεται και αναζητείται σαν text, αλλά δεν ζωγραφίζει τίποτε
Υπάρχει engine-free overload που δημιουργεί για σένα τον built-in recognizer, και αυτή είναι η επιλογή που πρέπει να χρησιμοποιούν οι περισσότεροι callers της built-in path. Recognition, Unicode validation, budget accounting και content construction ολοκληρώνονται πριν ανοίξει το copy-on-write transaction, άρα cancellation, budget overrun ή engine failure αφήνει το object graph και το version number ανέγγιχτα. Τα words φιλτράρονται δύο φορές: ο engine απορρίπτει οτιδήποτε κάτω από το δικό του per-glyph confidence gate 0.55, και μετά το THPDFOCRTextLayerOptions.MinimumConfidence (default 0.5) απορρίπτει ολόκληρες λέξεις κάτω από το όριο του 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 pages ως έχουν
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// engine-free overload: το HotPDF δίνει τον built-in bounded 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;
Ένας περιορισμός αξίζει να ειπωθεί καθαρά αντί να ανακαλυφθεί αργότερα. Το invisible layer χρησιμοποιεί shared synthetic unembedded Type0 font, αρκετό για search και copy σε κάθε viewer αλλά όχι αρκετό για την απαίτηση font embedding του ISO 19005. Αν το output πρέπει να είναι PDF/A, ο caller πρέπει να κάνει embed ξεχωριστά ένα conforming font. Και ένα OCR text layer φέρει geometry και όχι structure, άρα η reading order προκύπτει μόνο από τις θέσεις των glyphs· αν χρειάζεσαι logical order από σελίδα που έχει ήδη πραγματικό text, το structure-order text extraction με οδηγό το tag tree είναι άλλο εργαλείο για άλλο πρόβλημα
Πού σταματά ο built-in engine
Ο built-in engine είναι σκόπιμα στενός, και το να γνωρίζεις τα όριά του είναι αυτό που τον κρατά χρήσιμο. Στοχεύει high-contrast machine-printed ASCII από fonts κοντά στα πέντε template faces του, και οτιδήποτε έξω από αυτό επιστρέφει no word αντί για guess. Τα συγκεκριμένα όρια είναι:
- Images έως 4096 επί 4096 και 4.194.304 pixels, με recognition deadline 2000 ms και cooperative cancellation μέσω
THPDFCancellationToken - Alphabet 62 χαρακτήρων από ASCII letters και digits· χωρίς punctuation, accented characters ή CJK
- Μόνο axis-aligned text, στη page rotation που έχει ήδη κανονικοποιήσει ο renderer· τα skewed scans δεν κάνουν deskew
- Ambiguous glyph pairs μένουν unresolved, άρα μια σελίδα μπορεί να επιστρέψει partial words ή το diagnostic «found no unambiguous ASCII words»
Όταν αυτό το envelope είναι πολύ μικρό, η seam είναι το IHPDFOCREngine. Υλοποίησε το Recognize με τον δικό σου engine, δώσ’ τον στο three-argument overload του ApplyLoadedOCRTextLayer και ό,τι ακολουθεί — coordinate mapping, rotation handling, Unicode validation, budgets, atomic commit — παραμένει ίδιο. Το bitmap δανείζεται για τη διάρκεια του synchronous call και δεν πρέπει να διατηρηθεί. Για να επιβεβαιώσεις ότι το layer μπήκε σωστά, κάνε reload το saved file και τρέξε τη συνηθισμένη text path που περιγράφεται στο extracting text από loaded PDF στο Delphi· αν επιστρέψουν οι λέξεις, το layer είναι πραγματικό
Το built-in template-matching OCR, το invisible text layer, ο page renderer που τα τροφοδοτεί και το loaded-document text extraction που τα επαληθεύει διατίθενται στο ίδιο native VCL component, χωρίς external OCR runtime και χωρίς DLL για deployment δίπλα στην application. Αν χτίζεις document capture, archival ή search πάνω σε scanned PDFs σε Delphi ή C++Builder, το HotPDF Delphi PDF component σου δίνει ολόκληρο το pipeline σε μία dependency