מאמר טכני

חילוץ טקסט PDF מובנה ב-Delphi עם PDFium VCL

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

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

מדוע מחרוזת שטוחה היא הפלט השגוי לרוב העבודות?

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

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

סדר תוכן או פריסה פיזית?

TPdfStructuredTextOptions.ReadingOrder בוחר בין roContentOrder ל-roPhysicalLayout, והתשובה הנכונה תלויה במה שסומכים עליו יותר, המחולל או הגיאומטריה

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

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // מבוסס-1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // תקציב כשל-סגור

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

מה תיוג מוסיף שגיאומטריה לא יכולה?

כוונה. עם IncludeSemantics מופעל, בלוקים מ-PDF מתויג נושאים Kind שנשאב מעץ המבנה, כך שכותרת היא כותרת מפני שהמחולל אמר כך, לא מפני שהגופן שלה היה גדול מהממוצע. הסוגים מכסים את הצורות שחשובות לשימוש חוזר: cfParagraph, cfHeading עם HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure והחלופה הלא-מתויגת cfPlain

שדה Source רושם מאיפה הגיע כל סיווג, rosStructure עבור עץ המבנה ו-rosHeuristic עבור הסקה, וזה השדה שכדאי לרשום ביומן כשמחליטים עד כמה לסמוך על צינור חילוץ לאורך אוסף מסמכים. איורים הם מקרה מיוחד שכדאי להכיר: עבור בלוק cfFigure הטקסט מגיע מהתיאור החלופי ולא מגליפים כלשהם, מפני שלאיור אין תווים משלו. טקסט חלופי שלא תואם עדיין מיוצג במקום שיושמט, וזה מה שנותן לביקורת נגישות לראות שתיאור קיים גם כשדבר בעמוד לא מציג אותו. מודל התיוג עצמו מכוסה באימות עץ מבנה PDF/UA

רצועות נושאות את העיצוב ואת המקור

כל TPdfStructuredTextSpan מחזיק את הטקסט שלו, את גבולותיו במרחב העמוד, FontName, FontSize, FontWeight ו-Angle, בתוספת SourceStartIndex ו-SourceCharacterCount. רצועות נשברות היכן שהעיצוב משתנה, כך שמשפט עם שלוש מילים מודגשות הופך לשלוש רצועות, ושחזור הדגשה ב-HTML או ב-Markdown הוא עניין של קריאת תכונות ולא ניחוש משמות גופנים

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

תקציב, ושני מוני איכות

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

שני מונים בעמוד המוחזר מתארים איכות חילוץ ישירות. UnmappedCharacterCount סופר תווים ללא מיפוי Unicode שמיש, שזה התסמין הקלאסי של גופן תת-קבוצה מוטמע ללא CMap מסוג /ToUnicode; טקסט כזה נרנדר מושלם ומחולץ כלא-שימושי. GeometryFailureCount סופר תווים שתיבת התיחום שלהם לא ניתנת לקביעה, שפוגע בסדר פריסה פיזית. רשמו את שניהם ביומן. אוסף מסמכים שבו המספרים האלה עקבית קרובים לאפס אפשר לאנדקס בביטחון, ואוסף שבו הם לא אומר לכם שכמה מחוללים בצינור שלכם זקוקים לתשומת לב לפני שכל תוצאה בהמשך אמינה

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

ביצועים על עמודים אמיתיים

חילוץ פריסה פיזית הוא המצב היקר, והמימוש בנוי לעמודים שבאמת גדולים: סידור תווים רץ ב-O(n log n) ולא בסריקה חוזרת, מאגרי שורה ורצועה גדלים גיאומטרית במקום להקצות מחדש לכל תו, טקסט Unicode נבנה במאגרים ולא בשרשור מחרוזות, וחיפושי גופן לאובייקטי טקסט סמוכים במטמון. השילוב הזה הוא מה ששומר על עמוד צפוף בן 5,000 תווים צפוי במקום ריבועי

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

מודל הבלוקים גם ממופה בקלות למה שצינורות אחזור רוצים: כותרת עם הפסקאות שלה היא קטע עם כותרת, והגבולות מאפשרים לציטוט להצביע על מיקום בעמוד ולא רק על מסמך. PDFiumPas הוא רכיב ל-Delphi ול-Lazarus סביב מנוע PDFium, מתועד עם דוגמאות בדף רכיב PDFium ל-Delphi