מאמר טכני

חילוץ טקסט, תמונות וגופנים מ-PDF ב-Delphi עם PDFlibPas

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

losLab PDF Library, גרסת Pascal, מעניקה לקוד Delphi ו-C++Builder יותר מדרך אחת לקרוא כל אחד משלושת הזרמים הללו, והרמות נבדלות במה שהן מבטיחות. הטריק הוא להתאים את הרמה למשימה: אינדקס חיפוש, סוקר עריכה ומעבר בדיקה מוקדמת של PDF/A רוצים דברים שונים מאותו עמוד, ובחירה בקריאה הלא נכונה מבזבזת מאמץ או מייצרת פלט שלא ניתן לסמוך עליו

רמות חילוץ טקסט ומה כל אחת מבטיחה

GetPageText מקבל ערך אפשרויות מ-0 עד 8, ומספר זה בוחר מנוע ולא פורמט. ערכים 0 עד 2 מריצים מעבר קל המתאים לתצוגה מקדימה מהירה. ערכים 3 עד 8 עוברים דרך מנוע המודע לפריסה, שמבנה מחדש שורות ומרווחים לפי המיקום בפועל של הגליפים בדף. בטווח זה ההבדלים חשובים: 4 ו-6 מחלקים את הפלט למילים, 5 ו-6 פולטים רוחבי גליף, ו-7 מחזיר טקסט רגיל עם מטא-נתוני גופן, צבע וגוש שהושמטו במכוון. אפשרות 7 היא זו להזין לאינדקס חיפוש, כי האינדקס רוצה מילים ולא יותר

אף הגדרת אפשרות אינה יכולה להציל מסמך שמעולם לא נשא את המידע מלכתחילה. PDF ממפה קודי תווים לצורות גליפים, והדבר היחיד שממפה את הקודים חזרה לטקסט קריא הוא ToUnicode CMap של גופן (ISO 32000-1 §9.10). כאשר גופן מקוצץ נשלח ללא אחד כזה, כל חלצן תקוע. הספרייה הזו, ההעתק-הדבק בצופן, ערכת כלים מתחרה — כולם מצומצמים לניחוש לפי שמות גליפים או לא להחזיר דבר. התגובה המעשית היא זיהוי, לא גבורה. דרגו את הדף כבעל אמינות נמוכה ושלחו אותו ל-OCR, כי אינדוקס השטויות בשקט גרוע יותר מלהודות שאינכם יכולים לקרוא אותו

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

גושים ממוקמים: יחידת פגיעות חיפוש וסקירת עריכה

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

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

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

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

תמונות: זרמים מקוריים, לא צילומי מסך

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

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

בדקו את GetImageListItemFormatDesc לפני שתניחו כל הנחה לגבי פריט, כי מה שדף מפנה אליו הוא לעתים נדירות תמונה מסודרת אחת לכל תמונה גלויה. מסיכה רכה מופיעה כרשומה נפרדת משלה. אותו XObject חוזר לעיתים קרובות על פני עמודים רבים, אז הסירו כפילויות לפי גיבוב תוכן לפני שתארכבו ייצוא "כל התמונות", או שתכתבו את אותו לוגו מאה פעמים. JPEG ב-CMYK דורשים ניהול צבע שיוחל בהמשך, אחרת הם מרונדרים הפוך בצופנים שלוקחים את הערוצים כערכם הנקוב. כאשר רוצים מלאי כלל-מסמך ולא עמוד בכל פעם, FindImages יחד עם SetFindImagesMode סורקים את כל הקובץ במעבר אחד

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

גופנים: משטח ביקורת, לא תכונת ייצוא

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

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // font indexes start at 1, not 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

שימו לב לגבולות הלולאה. אינדקסי גופן רצים מ-1 עד FontCount, בעוד שאינדקסי גוש-הטקסט ורשימת-התמונות מספר פסקאות לעיל מבוססי אפס. העבירו מוסכמה אחת לשנייה ותקבלו שגיאת off-by-one שמדלגת על הגופן הראשון או רצה מעבר לסוף, והיא תעבור בדיקה מזדמנת כי לרוב המסמכים יש כמה גופנים והגופן השגוי עדיין נראה סביר. היו ברורים לגבי ההיקף גם. לממשק API זה אין ייצוא גופן ברמת בייט. אף קריאה אינה מחזירה את תוכנית הגופן המוטמעת כקובץ TTF או OTF, ומניה בתוספת בדיקת מטא-נתונים הוא המודל המתוכנן כולו. מודל זה עדיין מכסה את מה שעבודת ייצור באמת דורשת מגופנים: זיהוי תת-קבוצה לפי תבנית שם, בדיקות הטמעה לפני המרת ארכיון (גופן לא מוטמע הוא חוסם PDF/A קשה, כפי שמוסבר בבדיקת PDF/A ו-PDF/UA מוקדמת ב-Delphi), ואבחון קידוד כאשר אמינות החילוץ יורדת. קיים גם סיבה רישוי לכך שהגבול יושב כאן. תוכנית גופן מקוצצת היא חומר מורשה ו, בהיעדר רוב גליפיו, חסרת שימוש כגופן להתקנה ממילא. התייחסות אליו כמטא-נתוני ביקורת ולא כנכס ניתן לחילוץ היא הייעמדה שניתן להגן עליה

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

חילוץ בקנה מידה ללא טעינת מסמכים

בצינור אצווה, טעינת מסמך שלם רק כדי לקרוא עמוד אחד היא I/O מבוזבז, ועל פני קורפוס זה מצטבר במהירות. הגרסאות בקריאה אחת, ExtractFilePageText ו-ExtractFilePageTextBlocks, מקבלות שם קובץ, סיסמה ומספר עמוד ישירות ומדלגות על הטעינה המלאה. לקבצים בגודל גיגה-בייט קיימת הילוך נמוך יותר. נתיב הגישה הישירה פותח קובץ דרך קריאות xref בזרימה, כך ש-DAOpenFileReadOnly ואחריו DAExtractPageText נוגעים רק באובייקטים שאותו עמוד באמת צריך. הוא מגיע עם שינוי מוסכמה שכדאי לקחת אל הזיכרון: פונקציות DA מטפלות בעמודים לפי PageRef, נקודת אחיזה לייחוס אובייקט שמקבלים מ-DAFindPage, לעולם לא לפי מספר עמוד גולמי. העבירו את המספר היכן שהנקודת אחיזה שייכת והקריאה פועלת על האובייקט הלא נכון ללא העלאת שגיאה, שזהו הסוג הגרוע ביותר של טעות לאיתור באגים. שאר ערכת הכלים של גישה ישירה מפורטת במיזוג, פיצול וגישה ישירה של PDF גדול

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

גרסאות הערכה, פרויקטי הדגמה וההפניה המלאה של ממשק ה-API לחילוץ נמצאים בדף המוצר losLab PDF Library for Delphi