מאמר טכני

הוספת שכבת טקסט ניתנת לחיפוש לקובצי PDF סרוקים בדלפי

PDFium Component מוסיף שכבת טקסט ניתנת לחיפוש לעמודי PDF סרוקים מדלפי דרך ApplyOcrSearchLayer. הוא מציג כל עמוד נבחר, מוסר את הפיקסלים לספק OCR שאתם מספקים, וכותב את המילים שזוהו בחזרה כאובייקטי טקסט בלתי-נראים הממוקמים מעל המילים בסריקה. תמונת העמוד המקורית לעולם לא מפוענחת, מקודדת מחדש או מוחלפת, כך שהתוצאה החזותית היא בייט-לבייט העמוד שהתחלתם איתו

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

מה זו בדיוק שכבת טקסט ניתנת לחיפוש?

PDF סרוק הוא תמונה של מסמך. תוכן העמוד הוא תמונה גדולה אחת, ואין דבר לבחור, לחפש, להעתיק או לאנדקס. שכבת טקסט ניתנת לחיפוש מוסיפה אובייקטי טקסט אמיתיים מעל התמונה הזו כשמצב ההצגה מוגדר לבלתי-נראה, כך שצופים לא מציירים כלום אבל בחירה, חיפוש וחילוץ מוצאים את המילים בדיוק במקום שבו הן מופיעות

המיקום הוא כל המשחק. אם הטקסט הבלתי-נראה יושב כמה נקודות מהיישור, הדגשות בחירה נוחתות לצד המילים במקום עליהן, והעתקת פסקה מפיקה טקסט בסדר שגוי. זו הסיבה שהגיאומטריה חייבת להגיע מאותן טרנספורמציות ש-PDFium משתמש בהן כדי להציג את העמוד ולא מניחוש פרופורציונלי

מימוש הספק

חוזה הספק הוא מתודה אחת. היא מקבלת רשומת תמונת עמוד הנושאת מידות, stride, DPI, פורמט פיקסל ובייטי הפיקסלים עצמם, בתוספת אסימון ביטול, ומחזירה מילים או הודעת שגיאה:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels מחזיק שורות BGRA מקור-עליון באורך Image.Stride בייטים.
  // מסרו אותן למנוע שלכם, ואז מלאו רשומה אחת לכל מילה שזוהתה
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

מרובעים ולא מלבנים, מכיוון שסריקה לעיתים רחוקות ניצבת בדיוק לעמוד. מילה בעמוד מוטה מעט תופסת מקבילית, ו-TPdfOcrQuad נושא ארבע נקודות פינה כך שמילים מוטות ומסובבות שומרות על אזור בחירה מדויק. מנועים המדווחים רק על תיבות מיושרות-לצירים יכולים להשתמש ב-FromRectangle, שבונה את המרובע המנוון

למה מיקומי מילים לא יכולים להיות מוגדלים באופן פרופורציונלי?

מפתה להמיר קואורדינטת פיקסל לקואורדינטת עמוד על ידי חלוקה ברוחב ההצגה וכפל ברוחב העמוד. זה עובד רק עבור עמודים ללא סיבוב, CropBox זהה ל-MediaBox, ומקור באפס, והרבה מסמכים סרוקים נכשלים בלפחות תנאי אחד מאלה

PDFium Component ממפה כל אחת מארבע פינות המרובע בנפרד דרך FPDF_DeviceToPage, אותו מיפוי שהמציג השתמש בו כדי להפיק את הפיקסלים, כך שרשומות /Rotate ותיבות חיתוך מוזזות מטופלות מעצם הבנייה. המטריצה האפינית עבור אובייקט הטקסט נבנית אז משלוש מהנקודות הממופות, הפינות שמאל-תחתונה, ימין-תחתונה ושמאל-עליונה, מה שבדיוק מספיק לביטוי מיקום, קנה מידה, סיבוב והטיה

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

הרצה על מסמך

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

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // רזולוציית זיהוי
    Options.MinConfidence := 0.60;       // השמטת מילים לא ודאיות
    Options.SkipPagesWithText := True;   // אל תיגעו בעמודים דיגיטליים-מלידה
    Options.ContinueOnError := True;     // עמוד גרוע אחד לא יעצור את העבודה
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText ראוי להדגשה בארכיונים מעורבים. PDF שכבר נושא טקסט אמיתי, בין אם דיגיטלי-מלידה או שעובד בעבר, מקבל שכבת טקסט שנייה אם מריצים עליו OCR בעיוורון, והכפילות גורמת לחילוץ להחזיר כל מילה פעמיים. הסטטוס לכל עמוד popsSkippedExistingText אומר לכם בדיוק אילו עמודים נותרו ללא נגיעה

תקציבים, ביטול והכלת כשל

לכל כמות שמסמך עוין או פשוט עצום יכול לנפח יש תקרה: פיקסלים לעמוד ובסך הכל, מילים לעמוד ובסך הכל, ותווים למילה. כולם נבדקים לפני שהעמוד נכתב, לא אחרי, ואומדן הפיקסלים מחושב ממידות העמוד וה-DPI לפני שכל מפת סיביות מוקצית. הגדלת ה-DPI מ-150 ל-300 מרבעת את הזיכרון לעמוד, כך שהתקרה לעמוד היא הפרמטר לכוונן ראשון כשעבודת אצווה מתחילה להיכשל בפורמטים גדולים

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

הכלת כשל היא לכל עמוד. הספרייה אוספת את מזהי האובייקטים שהיא הכניסה לעמוד וקוראת ל-FPDFPage_GenerateContent פעם אחת, אחרי שכל המילים הוצבו. אם משהו נכשל באמצע, בין אם שגיאת ספק או בעיית גופן, האובייקטים שהוכנסו לאותו עמוד מוסרים בסדר הפוך ותוכן העמוד נוצר מחדש, כך שעמוד שנכשל חוזר למצבו המקורי במקום לשמור על חצי שכבת טקסט. לולאת המסמך אז ממשיכה או נעצרת לפי ContinueOnError, והעמוד הפעיל תמיד משוחזר

אימות שהתמונה באמת לא נגעה בה

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

אחר כך, אמתו את צד הטקסט על ידי חילוץ מהקובץ המעובד ואישור שמיקומי המילים נוחתים על הסריקה. נתיב החילוץ הוא אותו הנתיב המתואר בחילוץ טקסט ממסמכי PDF, ולבדיקה חזותית מהירה של יישור, הצגת עמודים לתמונות כמו בהמרת עמודי PDF ל-JPEG מאפשרת להציג תיבות מילה בשכבת-על מעל הסריקה

שכבת OCR, הצגה, חילוץ ועריכה כולם רצים כנגד אותו אובייקט מסמך בדלפי, C++Builder ו-Lazarus; מלוא שטח ה-API מתואר בעמוד רכיב PDFium Component לדלפי