מאמר טכני

חילוץ טקסט מקובצי PDF עם רכיב PDFium ב-Delphi

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

פתיחת המסמך ומלכודת הכשל השקט

הקלאס TPdf פותח קובץ על ידי הגדרת FileName והעברת המאפיין Active := True. פרט קריטי: Active := True אינו מעלה חריגה לעולם. אם הקובץ חסר, מוגן בסיסמה, או פגום, ספריית PDFium לוכדת את השגיאה באופן פנימי והמאפיין Active פשוט נשאר False. המשמעות היא שכל לולאת חילוץ חייבת להתגונן מפני זה

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

קבצים מוגני סיסמה זקוקים להגדרת Pdf.Password := '...' לפני Active := True. אין הזדמנות שנייה: ברגע ש-Active נכשל, עליכם לסגור ולפתוח מחדש עם הסיסמה הנכונה

חילוץ דף אחר דף עם Character[]

הגישה ברמה הנמוכה ביותר עוברת על כל תו בכל דף. הגדירו את Pdf.PageNumber כדי לטעון את שכבת הטקסט עבור אותו דף, ולאחר מכן בצעו לולאה על פני רשומות CharacterCount באמצעות המאפיין Character[]. כדאי לבדוק שני דגלים בכל רשומה: CharacterGenerated[i] מסמן גליפים סינתטיים שהוכנסו על ידי המרנדר (למשל, מקפים רכים במעברי שורות) שאין להם ערך יוניקוד אמיתי, והמאפיין CharacterMapError[i] מסמן ש-PDFium לא יכלה למפות את הגליף לנקודת קוד, מה שקורה עם קידודי גופנים חסרי טבלת ToUnicode

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

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

חילוץ מובנה עם ReadablePageContent

הפונקציה ReadablePageContent עולה שלב אחד למעלה: היא מחזירה רשומת TPdfReadableContent שמערך ה-Fragments שלה נושא קטעי תוכן מתויגים, כל אחד עם Kind המזהה פסקאות, כותרות, פריטי רשימה, תאי טבלה וכדומה. כאשר ה-PDF נושא עץ מבנה (בדקו את Pdf.IsTagged), המקור הוא rosStructure וסדר הקריאה הוא סמכותי. עבור קבצים לא מתויגים, ספריית PDFium נסוגה ל-rosHeuristic, המקבץ תווים לפי תיבות החסימה שלהם ליחידות קריאה סבירות אך אינו יכול להבטיח דיוק

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

אם Content.Source = rosHeuristic והפלט שלכם נראה משובש, כנראה ששכבת הטקסט של המסמך לא נכתבה תוך מחשבה על סדר הקריאה. בנקודה זו התיקון האמין היחיד הוא ייצוא מחדש מאפליקציית המקור עם תיוג מתאים, או הרצת שלב עיבוד נוסף הממיין את מקורות התווים לפי Y ואז X

מה שמעניקים לכם CharacterOrigin ו-CharacterRectangle

שני המאפיינים מחזירים את מיקום התו במרחב הדף (נקודות, ראשית הצירים בפינה השמאלית התחתונה, Y גדל כלפי מעלה). המאפיין CharacterOrigin[i] הוא נקודת העוגן של קו הבסיס של הגליף; המאפיין CharacterRectangle[i] הוא תיבת החסימה המלאה. אלו הם אבני הבניין לכל דבר שמעבר לטקסט פשוט: זיהוי גבולות עמודות, קיבוץ תווים לשורות על ידי השוואת קואורדינטות Y בתוך טווח סבילות, או בניית מפת בדיקת פגיעה לבחירת טקסט במציג. אם אתם צריכים למצוא איזה תו יושב תחת לחיצת עכבר, הפונקציה CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) מבצעת חיפוש זה ישירות מבלי שתצטרכו לעבור על מלבנים

הצבת קובץ ה-DLL במקומו

רכיב PDFium מאציל את כל ניתוח ה-PDF לקובץ DLL מקומי, pdfium32.dll או pdfium64.dll בהתאם לפלטפורמת היעד שלכם. הרכיב שולח סקריפט CopyDlls.bat המעתיק את הקובץ הנכון לספריית המערכת של Windows. הרצתו כמנהל פעם אחת במחשב פיתוח מספיקה; לצורך הפצה אתם מעתיקים את ה-DLL לצד קובץ ההרצה של האפליקציה במקום זאת. הגרסאות המופעלות-V8‏ (pdfium32v8.dll,‏ pdfium64v8.dll) גדולות משמעותית ונחוצות רק אם קובצי ה-PDF שלכם מכילים JavaScript שחייב לפעול. לחילוץ טקסט טהור, הגרסה הסטנדרטית היא הבחירה הנכונה

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

שימוש ב-FontSize[] לצד Character[] לצורך ניתוח עימוד

מעבר לטקסט פשוט, ה-API ברמת התו חושף את FontSize[i], המחזיר את גודל הנקודה המרונדר של כל גליף. בשילוב עם CharacterOrigin[i] ו-CharacterRectangle[i], זה מאפשר לכם להבחין בין טקסט גוף לכותרות מבלי להסתמך על עץ המבנה. ריצת תווים שבה גודל הגופן קופץ מעל סף מסוים היא כמעט בוודאות כותרת במסמך לא מתויג. אותה טכניקה חלה על זיהוי כתוביות (טקסט קטן מתחת לתיבת חסימת תמונה) או הערות שוליים (טקסט קטן ליד תחתית הדף). שום דבר מזה אינו דורש רינדור; כל שלושת המאפיינים קוראים ישירות משכבת הטקסט ש-PDFium בונה במהלך Active := True

ניואנס אחד: FontSize[i] משקף את הגודל לאחר החלת ה-CTM (מטריצת הטרנספורמציה הנוכחית) של הדף, כך שמסמך שבו המחבר שינה את קנה המידה של הדף כולו ידווח על גדלים מותאמים באופן יחסי. אם אתם משווים גדלים בין דפים בעלי מימדי דף שונים, נרמלו מול גובה ה-MediaBox של כל דף לפני קבלת החלטות לגבי ערכי סף

כתיבת הפלט לקובץ

מחלקת TStringList של Delphi מטפלת בפלט UTF-8 בצורה נקייה מאז גרסת XE. הגדירו את WriteBOM := False אם אתם צריכים קובץ ללא BOM (צרכנים רבים בהמשך נחנקים מ-BOM מוביל)

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

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

ממשקי ה-API של Character[],‏ CharacterCount,‏ CharacterOrigin[],‏ CharacterRectangle[],‏ ReadablePageContent ו-CharacterIndexAtPos המוצגים כאן הם חלק מרכיב PDFium עבור Delphi ו-C++Builder