מאמר טכני

Tesseract OCR ל-PDF ניתן לחיפוש בדלפי עם HotPDF

HotPDF הופך עמודי PDF סרוקים ל-PDF ניתן לחיפוש עם Tesseract דרך HPDFCreateTesseractOCREngine, factory שעוטף הרצת Tesseract מותקנת מקומית כ-IHPDFOCREngine. מעבירים את המנוע הזה אל ApplyLoadedOCRTextLayer, שמרנדר כל עמוד, מריץ את Tesseract פעם אחת לכל עמוד, מפענח את פלט ה-TSV ברמת המילה שלו, וכובש שכבת טקסט Unicode בלתי נראית עבור כל העמודים המבוקשים בטרנזקציה אחת, או עבור אף אחד מהם

pipeline OCR של HotPDF לכל עמוד: רנדור העמוד ב-DPI שהוגדר, שמירת input.bmp בספריית HotPDF-OCR פרטית, הפעלת תהליך הבן של Tesseract עם tessedit_create_tsv, פענוח ה-TSV בן שתים-עשרה העמודות, סינון מילים לפי ביטחון, וכיבוש שכבת הטקסט הבלתי נראית עבור כל העמודים המבוקשים או אף אחד
ה-adapter מחליף רק את הזיהוי: רנדור, פענוח, אימות וה-commit של הכול-או-כלום נשארים ב-pipeline שכבת הטקסט הקיים, כך שהקוד במורד הזרם לעולם לא משתנה

הסיבה שה-adapter הזה קיים היא היקף. מנוע ה-OCR המובנה של התאמת תבניות מכוון לצמצום במכוון: אותיות וספרות ASCII מודפסות-מכונה, ולא עוד דבר. חשבוניות עם שמות מנוקדים, חוזים בסינית וארכיונים רב-לשוניים זקוקים למזהה אמיתי עם מודלי שפה מאומנים, ו-Tesseract הוא המועמד הברור כי הוא תוכנית שורת פקודה שאפשר להציב לצד היישום שלכם. לקרוא לתוכנית חיצונית מתוך ספריית מסמכים נשמע שולי. זה לא, ורוב הקוד המעניין ב-adapter עוסק במה קורה כשהתוכנית מתנהגת רע, נתקעת, מבוטלת, או יורשת דברים שלעולם לא אמורה לראות

איך HotPDF מניע את Tesseract מיישום דלפי?

HotPDF מריץ את Tesseract כתהליך בן נסתר לכל עמוד, מזרים לו bitmap שרונדר וקורא בחזרה קובץ TSV, וחושף את התוצאה דרך אותו תפר IHPDFOCREngine שהמנוע המובנה משתמש בו. שום דבר במורד הזרם לא משתנה: מיפוי קואורדינטות, טיפול בסיבוב, אימות Unicode, סינון ביטחון וה-commit האטומי הם pipeline שכבת הטקסט שכבר יש לכם. ה-factory חי ביחידה HPDFTesseractRecognition ומאמת בלהיטות: ההרצה חייבת להתקיים, ספריית ה-tessdata חייבת להתקיים, הפסק-הזמן חייב להיות בין 1 ל-3,600,000 מילישניות, ומזהה השפה רשאי להכיל רק אותיות ASCII, ספרות, _ ו-+. הבדיקה האחרונה הזאת חשובה כי מחרוזת השפה נוחלת בסופו של דבר אל שורת פקודה, ו-eng+chi_sim הוא ערך לגיטימי של Tesseract בזמן שכל דבר עם מרכאות או רווחים אינו

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // מעלה EArgumentException עבור הרצה חסרה, tessdata חסרה,
  // מזהה שפה שגוי, או פסק-זמן מחוץ ל-1..3600000 ms
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // כמה מודלים מחוברים עם '+'
    120000);            // מגבלה לכל עמוד, ברירת המחדל היא 60000
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    Options.CancellationToken := Token;
    // רשימת עמודים ריקה פירושה כל עמוד; עמודים עם טקסט מדולגים כברירת מחדל
    if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words accepted, ', Info.DroppedWordCount, ' dropped');
      Doc.SaveLoadedDocument(TargetFile);
    end
    else
      case Info.Status of
        otlsCancelled:      Writeln('Cancelled, document unchanged');
        otlsEngineError:    Writeln('Engine: ', string(Info.Diagnostic));
        otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
      else
        Writeln(string(Info.Diagnostic));
      end;
  finally
    Doc.Free;
  end;
end;

עבור כל עמוד, ה-Recognize יוצר ספרייה פרטית תחת נתיב ה-temp בשם HotPDF-OCR-{GUID}, שומר את ה-bitmap שרונדר כ-input.bmp, ומפעיל את tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, כשכל ארגומנט נתיב מוקף מרכאות לפי כללי ההימלטות של שורת הפקודה של Windows עבור לוכסנים אחוריים ומרכאות מוטמעות. ערך ה---dpi הוא ה-DPI של הרנדור מ-THPDFOCRTextLayerOptions.DPI, ולכן Tesseract לעולם לא צריך לנחש את הרזולוציה ממטא-נתוני התמונה, וה---psm 3 מבקש פילוח עמודים אוטומטי לגמרי. המנוע מדווח על עצמו כ-Tesseract (local CLI), מה שנוחת ב-Info.EngineName. Tesseract ומודלי השפה שלו אינם חבורים עם HotPDF; להתקין אותם זו עבודת היישום

למה ה-parser של ה-TSV כה קפדני?

ה-parser של ה-TSV ב-HotPDF מכשיל את העמוד כולו על כל שורה פגומה, כי רשימת מילים שפוענחה חלקית מניבה שכבת טקסט שחולקת בשקט עם התמונה. לפלט ה-TSV של Tesseract יש כותרת קבועה של שתים-עשרה עמודות, מ-level ועד text, ו-HotPDF משווה את השורה הראשונה מול הכותרת המדויקת הזאת אחרי הסרת סימן סדר בתים אופציונלי. כל שורה שאחריה חייבת להתפצל לבדיוק שנים-עשר שדות, והפיצול עוצר אחרי הטאב ה-11 כך שטאב בתוך הטקסט המזוהה נשאר חלק מהמילה במקום ליצור עמודה 13. רק שורות של רמה 5 הן מילים; רמות 1 עד 4 מתארות עמודים, בלוקים, פסקאות ושורות, והן מדולגות. גם שורות רמה 5 שהטקסט שלהן ריק או רווח טהור מדולגות, כי למילה ריקה יש תיבה אבל אין לה מה לאתר או לחפש. כל שאר הדברים נבדקים קשה: גיאומטריה מספרית שלמה, ביטחון שמפוענח עם פורמט en-US אינווריאנטי כך שלוקל גרמני לא יקרא את 93.5 כזבל, תיבה שנמצאת כולה בתוך ה-bitmap, וביטחון בין 0 ל-100. כשל יחיד מעלה חריגה, המנוע מחזיר False, ומערך המילים מנוקה. בדיקות הרגרסיה כוללות בדיוק את המקרה הזה: מילה תקינה אחת ואחריה שורה שבורה חייבות להניב אפס מילים, לא אחת

שישה שערים שכל שורת TSV של Tesseract עוברת ב-HotPDF: כותרת מדויקת של שתים-עשרה עמודות, בדיוק שנים-עשר שדות, רמה 5 בלבד, טקסט לא ריק, תיבה בתוך ה-bitmap, וביטחון מ-0 עד 100 המפוענח בצורה אינווריאנטית, שבהם שורה שבורה אחת מכשילה את העמוד כולו עד לאפס מילים
רשימת מילים שפוענחה חלקית הייתה חולקת בשקט עם התמונה, ולכן ה-parser מסרב לעמוד כולו על השורה הפגומה הראשונה במקום לשמור את המילים שכבר קרא
// מקוצר מהלולאה של רמה 5 ב-HPDFLocalTSVRecognition
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // שורות עמוד/בלוק/פסקה/שורה
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // מילות רווח אין להן מיקום
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
  not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
  not TryStrToFloat(Fields[10], Confidence, Settings) then
  raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
  (Int64(X) + W > Request.Bitmap.Width) or
  (Int64(Y) + H > Request.Bitmap.Height) or
  not ((Confidence >= 0) and (Confidence <= 100)) then
  raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100;  // ה-pipeline מצפה ל-0..1

השורה האחרונה הזאת מקיימת אינטראקציה עם ברירת מחדל שאולי לא ציפיתם לה. ביטחון Tesseract רץ מ-0 עד 100, ה-pipeline עובד בין 0 ל-1, וה-THPDFOCRTextLayerOptions.MinimumConfidence ברירת המחדל שלו 0.5, ולכן כל מילת Tesseract מתחת ל-50 נספרת ב-Info.DroppedWordCount ולעולם לא מגיעה אל העמוד. על סריקה נקייה של 300 DPI זו רצפה סבירה. על פקס רועש זה יכול להפיל נתח מפתיע מהעמוד, והצעד הנכון הוא להביט במספר המופלים לפני שמורידים את הסף, כי מילים בעלות ביטחון נמוך הן בדיוק אלה שהכי סביר שהן שגויות

מה תהליך הבן של Tesseract יורש?

תהליך הבן של Tesseract יורש מ-HotPDF בדיוק שני handle-ים: handle של NUL עבור קלט ופלט סטנדרטי, ו-handle של קובץ עבור שגיאה סטנדרטית. הדיוק הזה הוא הנקודה. CreateProcess עם bInheritHandles = True היא הדרך להעביר handles סטנדרטיים לבן, אבל לבדה היא מעבירה כל handle יורש בתהליך המארח, כולל קבצים, pipes ואירועים שנפתחו על ידי קוד לא קשור ביישום שלכם. הבן אז מחזיק את האובייקטים האלה חיים עד שהוא יוצא, כך שקובץ נשאר נעול או ש-pipe לעולם לא רואה את סופו בזמן ש-Tesseract טוחן עמוד. HotPDF סוגר את הפער הזה עם רשומת הפעלה מורחבת: STARTUPINFOEX, רשימת תכונות שנושאת PROC_THREAD_ATTRIBUTE_HANDLE_LIST, ודגל היצירה EXTENDED_STARTUPINFO_PRESENT. עם רשימת ה-handle-ים במקומה, bInheritHandles עדיין חייב להיות True, אבל רק ה-handle-ים הרשומים חוצים את הגבול. אותה חשיבה של הכלה מניעה את בידוד מפענחי תמונות PDF בתהליכי עבודה, שם הבן הוא קוד לא מהימן; כאן הבן מהימן, אבל המארח אינו הבעלים היחיד של טבלת ה-handle-ים שלו

ירושת handle-ים של תהליך בן Tesseract ב-HotPDF: CreateProcess רגיל עם bInheritHandles מעביר לבן כל handle יורש של קובץ, pipe ואירוע, בזמן ש-STARTUPINFOEX עם PROC_THREAD_ATTRIBUTE_HANDLE_LIST מגביל את הקבוצה אל handle NUL עבור stdin ו-stdout בתוספת handle קובץ ה-stderr
בלי רשימת התכונות הבן מחזיק אובייקטים לא קשורים חיים עד שהוא יוצא, נועל קבצים ומרעיב pipes; עם הרשימה, רק שני ה-handle-ים הרשומים חוצים את הגבול
// הקבועים מוצגים בשמם; המקור מעביר את ערכיהם המספריים
// שני ה-handle-ים נוצרים עם bInheritHandle = True
InheritedHandles[0] := NullHandle;    // stdin ו-stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt בספרייה הפרטית
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
  PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
  @InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
  True,                                        // נדרש על ידי רשימת ה-handle-ים
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

למה ריצת OCR שבוטלה יכולה להיראות ככשל מנוע?

ריצת OCR שבוטלה נראית ככשל מנוע כי ה-IHPDFOCREngine.Recognize מחזיר בוליאני יחיד, ו-False פירושו גם "Tesseract נכשל" וגם "המשתמש לחץ על ביטול". ה-adapter דוגם את token הביטול ואת פסק-הזמן כל 25 מילישניות בזמן שהבן רץ, וכשה-token יורה הוא מעלה בתוך Recognize, לוכד את החריגה של עצמו, מנקה, ומחזיר False עם אבחנה. אם ה-pipeline התייחס אל זה כאל שגיאת מנוע, הקורא היה רואה otlsEngineError עבור עבודה שהמשתמש עצר במכוון. ה-ApplyLoadedOCRTextLayer לכן בודק את ה-token קודם בכל פעם ש-Recognize מחזיר False, והופך את התוצאה לכשל מנוע רק אם ה-token לא הוגדר. הסדר הזה שומר על החוזה הרב-עמודי: זיהוי, אימות, חשבונאות תקציב ובניית תוכן רצים עבור כל עמוד מבוקש לפני שטרנזקציית הגרף נפתחת, כך שביטול בעמוד 40 מתוך 50 מדווח otlsCancelled ומשאיר את המסמך, כולל 39 העמודים הראשונים, ללא מגע. אין קובץ חפיש חלקית שצריך להסביר אחר כך, והטיפול ביתר הכשלים הולך באותו סגנון חסום:

  • פסק-הזמן הוא לכל קריאת Recognize, נמדד מתחילתה, ולכן ברירת המחדל של 60,000 ms חלה על כל עמוד ולא על המסמך כולו
  • בן שעדיין רץ בפסק-זמן או בביטול מופסק, ממתינים לו עד 5 שניות, וספרייתו הפרטית נמחקת בבלוק finally
  • output.tsv מוגבל ב-64 MiB וה-stderr.txt ב-1 MiB, עם בדיקה בזמן שהבן רץ וגם אחרי שהוא יוצא
  • ספירת המילים ויחידות הקוד UTF-16 מוגבלות לכל עמוד על ידי התקציבים הנותרים MaxWordsPerPage, MaxTotalWords ו-MaxTextCodeUnits, וחריגה מהם מכשילה את הריצה במקום לקצץ את רשימת המילים
  • הפלט הסטנדרטי הולך אל NUL כי Tesseract כותב output.tsv, בזמן שהשגיאה הסטנדרטית הולכת אל קובץ כך שקוד יציאה שאינו אפס מדווח עם עד 4,096 תווים מהתלונה של המנוע עצמו — בדרך כלל הדרך המהירה ביותר לגלות שקובץ .traineddata חסר

איך המילים המזוהות הופכות לשכבת טקסט בלתי נראית

HotPDF כותב את מילות Tesseract כטקסט בלתי נראה באמצעות מצב רינדור טקסט 3, מצב הלא-מילוי-ולא-קו-מתאר המוגדר ב-ISO 32000-1 §9.3.6, כך שהעמוד עדיין מציג את התמונה הסרוקה בזמן שחיפוש והעתקה עובדים על המילים המזוהות. זרם התוכן פותח BT עם 3 Tr, וכל מילה מקבלת מטריצת Tm בקו הבסיס שלה, גודל פונט שנגזר מגובה התיבה בפיקסלים ב-DPI של הרנדור, ו-Tz של קנה מידה אופקי שמותח את ריצת ה-glyph אל רוחב התיבה הנמדד, וזו הסיבה שהדגשת חיפוש נוחתת על המילה בתמונה ולא נודדת על פניה

ל-TSV של Tesseract יש תיבות אבל אין קווי בסיס, ולכן ה-adapter מדווח על כל מילה בלי אחד וה-pipeline מעריך את קו הבסיס בחמישית גובה התיבה מעל הקצה התחתון. הטקסט עצמו עובר דרך פונט Type0 לא מוטמע משותף עם קידוד Identity-H ו-CMap של ToUnicode שנוצר, CID אחד לכל scalar Unicode נבדל לאורך הריצה כולה, וכך סינית, לטינית מנוקדת ותווי מישור משלים כולם שורדים העתקה וחיפוש. לעיצוב הזה שני גבולות ששווה להצהיר עליהם מראש: ריצה אחת יכולה לשאת לכל היותר 65,535 scalar-ים נבדלים, והפונט הלא מוטמע לא מקיים את דרישת הטמעת הפונטים של ISO 19005, ולכן פלט PDF/A זקוק לפונט תואם שמוטמע בנפרד. לבדוק את התוצאה פשוט ושווה אוטומציה: לשמור, לטעון מחדש ולהריץ את נתיב הטקסט הרגיל של מסמך טעון מחילוץ טקסט מ-PDF טעון בדלפי; אם המילים חוזרות בעמודים הצפויים, השכבה אמיתית

RapidOCR ומנועים אחרים על אותו פרוטוקול TSV

HotPDF עושה שימוש חוזר באותו רץ תהליכים ו-parser של TSV עבור RapidOCR דרך HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), שהוא הבחירה השימושית יותר לסריקות סינית מפושטת. שורת הפקודה זהה חוץ מזה שנתיב סקריפט הגשר מוכנס אחרי הרצת ה-Python, והשפה קבועה כ-chi_sim. HotPDF משלוח את הגשר כ-tools/OCR/rapidocr_tsv.py; הוא מצפה לחבילות rapidocr ו-onnxruntime בתוספת שלושה מודלי ONNX מקומיים, מנטרל הורדות מודלים אוטומטיות, וכותב TSV בצורת Tesseract כך שצד הדלפי לא זקוק ל-parser שני. שם המנוע המדווח ב-Info.EngineName הוא RapidOCR (local ONNX). הצורה הזאת מרמזת על המתכון הכללי: כל מזהה שאפשר לעטוף בסקריפט קטן שמקבל את רשימת הארגומנטים בסגנון Tesseract ומשגר TSV בן שתים-עשרה העמודות יורש בידוד handle-ים, פסק-זמן, ביטול, תקציבי פלט ואת ה-commit של הכול-או-כלום בחינם. ה-adapter-ים עובדים רק על Windows, מריצים עמוד אחד בכל פעם באופן סינכרוני, ולא מיישרים אלכסון או מעבדים מראש את התמונה מעבר למה שהרנדרר מייצר, ולכן איכות התמונה בכניסה עדיין מציבה את התקרה למה שיוצא

ה-adapter-ים של Tesseract ו-RapidOCR, כותב שכבת הטקסט הבלתי נראית, מרנדר העמודים שמזרים אליהם, וחילוץ הטקסט שמאמת את התוצאה מגיעים כולם באותו רכיב VCL טבעי עבור Delphi ו-C++Builder. אם אתם מוסיפים OCR ליישום לכידת מסמכים או ארכוב, רכיב HotPDF ל-PDF בדלפי נותן לכם את ה-pipeline כשרק מנוע ה-OCR עצמו נותר להתקנה