מאמר טכני

חילוץ טקסט מ-PDF טעון בדלפי באמצעות HotPDF

רכיב HotPDF מחלץ טקסט Unicode מכל קובץ PDF שתטענו בדלפי באמצעות שתי קריאות: ExtractLoadedPageText מחזירה את טקסט זרימת הקריאה של העמוד, ו-ExtractLoadedPageTextLayout (שנוספה בגרסה v2.263.0) משחזרת את המבנה הוויזואלי של העמוד כטקסט פשוט, כך שעמודות, הזחה ויישור טבלאות נשמרים בפלט. שתי הפונקציות פועלות על מסמכים ש-HotPDF לא יצר, שזהו המקרה החשוב באמת: החשבונית שלקוח שלח לכם בדוא"ל, הדוח שמשרד סריקה סיפק, או החוזה שנוצר על ידי תוכנה שאף אחד כבר לא זוכר את שמה

הגעה לתוצאה זו דרשה מנגנונים מורכבים יותר ממה ששתי החתימות מרמזות, מכיוון שקובץ PDF אינו שומר טקסט כפי שקובץ טקסט עושה. מאמר זה מלווה את שני מצבי החילוץ, ולאחר מכן חושף את שלושת החלקים שמתחתיהם — קורא ה-CMap, מפרש זרם התוכן (content stream interpreter) ושרשרת הנסיגה לפענוח גופנים (font decode fallback chain) — מכיוון שהבנת אופן המיפוי היא ההבדל בין משיכת כתפיים מול פלט משובש לבין אבחון מדויק שלו

מדוע חילוץ טקסט קשה יותר מקריאת מחרוזות מתוך הקובץ?

זרם תוכן של PDF (content stream) מתעד קודי תווים, לא תווים. האופרטורים Tj ו-TJ (תקן ISO 32000-1 §9.4.3) נושאים מחרוזות של בתים שהמשמעות שלהם תלויה לחלוטין בגופן שנבחר על ידי ה-Tf הקודם: בית 0x41 עשוי להיות האות A תחת WinAnsi, גליף שרירותי בגופן תת-קבוצה, או חצי מ-CID בן שני בתים בגופן CJK מורכב. תקן ISO 32000-1 §9.10 מגדיר חילוץ טקסט בדיוק כבעיית פענוח זו — מיפוי כל קוד בחזרה ל-Unicode באמצעות כל מידע שמילון הגופן מספק — והתקן מצהיר במפורש שקובץ תואם אינו נדרש לספק מספיק מידע כדי לעשות זאת

הפסקה האחרונה מסבירה כל דיווח על באג בנוסח "מדוע העתק-הדבק מה-PDF הזה מייצר ג'יבריש" שנתקלתם בו אי פעם. מחולל PDF המטמיע גופן תת-קבוצה ללא טבלת /ToUnicode כותב קובץ שמתרנדר בצורה מושלמת אך מחולץ כשטויות, מכיוון שמיפוי הקוד-לגליף קיים אך מיפוי הקוד-ל-Unicode מעולם לא סופק. כל ממשק API לחילוץ אמין הוא לפיכך שרשרת של פתרונות נסיגה (fallbacks) המאמצים את עיקרון "המאמץ המרבי", והשאלה השימושית היא עד כמה עמוקה השרשרת הזו

חילוץ זרימת קריאה באמצעות ExtractLoadedPageText

עבור אינדוקס חיפוש, התאמת מילות מפתח או הזנת טקסט לצינור ניתוח, ExtractLoadedPageText היא הקריאה שאתם מחפשים. החתימה היא function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — אינדקסי הדפים מבוססי אפס, התוצאה מתקבלת כ-UnicodeString של דלפי, והפונקציה מחזירה False כאשר לעמוד אין זרם תוכן קריא, במקום להעלות חריגה

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText now holds the reading-flow text of the document
  finally
    Pdf.Free;
  end;
end;

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

מתי כדאי להשתמש בחילוץ משמר פריסה במקום זאת?

ExtractLoadedPageTextLayout היא הקריאה הנכונה בכל פעם שהמיקום נושא משמעות: טבלאות, טפסים, רשימות קוד, כל דבר שאתם מתכוונים להשוות (diff), לחפש (grep) או לנתח לפי עמודה. במקום לשטח גליפים לזרם, היא מקבצת אותם לשורות בסיס (baselines), ממיינת כל שורת בסיס לפי ציר ה-X, ומשחזרת רווחים אופקיים ואנכיים על גבי רשת תווים בעלת רווח קבוע (monospace) המחושב לפי התקדמות הגליף החציונית וגודל הגופן. מרווחים רחבים בין קטעי טקסט באותה שורת בסיס הופכים לרצפים של רווחים; מרווחים גדולים בין שורות בסיס הופכים לשורות ריקות. התוצאה נקראת בדיוק כפי שהעמוד נראה

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Columns, indentation and table alignment survive as
  // spaces and blank lines on a character grid
end;

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

כיצד HotPDF מפענח קודי תווים ל-Unicode?

רכיב HotPDF מפענח כל קוד תו דרך שרשרת פתרונות נסיגה בעלת סדר עדיפויות מוגדר: ראשית ה-CMap של /ToUnicode המוטמע בגופן, לאחר מכן ערך ה-/Encoding (זרם או CMap בעל שם), ואז — עבור גופנים מורכבים — קובצי ה-CMap הסטנדרטיים של Adobe עבור אוספי תווים כגון Adobe-GB1, Adobe-CNS1, Adobe-Japan1, ו-Adobe-KR, ולבסוף טבלאות WinAnsi ו-MacRoman המובנות עבור גופנים פשוטים. אסטרטגיה שאינה יכולה לספק תשובה נסוגה בשקט לבאה בתור במקום להעלות שגיאה, וקוד שממצה את כל השרשרת מפענח ל-0 כך שהקורא יכול לספור החמצות במקום לנחש

ה-CMap של /ToUnicode (תקן ISO 32000-1 §9.10.3) ממוקם ראשון מכיוון שהוא המיפוי שכותב המסמך יצר במיוחד עבור חילוץ. נתיב ה-CMap הסטנדרטי של Adobe חשוב עבור מסמכי CJK המשתמשים ב-CMaps מוגדרים מראש כמו UniGB-UTF16-H במקום להטמיע דבר: HotPDF מספק את קובצי האוסף תחת ספריית resources\CMap שלו, מאתר אותם ביחס לקובץ ההרצה בזמן ריצה, ומבצע שמירה במטמון (caching) של כל מפה מנותחת לכל תהליך — פרט שחשוב לדעת מכיוון שהגדולה שבהן, מפת Adobe-GB1, היא בערך 2MB של טקסט מקור שאינכם רוצים לנתח מחדש עבור כל עמוד. אם הספרייה אינה קיימת, המפענח פשוט מדלג על CMaps המבוססים על הדיסק ועובד עם טבלאות מוטמעות בתוספת הקידודים המובנים. זוהי תמונת הראי של בעיית עיצוב הטקסט (shaping) המכוסה בעיצוב טקסט של כתבים מורכבים עם HotPDF, שבה אותה הבחנה בין קוד לגליף עומדת בפנינו בזמן הכתיבה

שתי מלכודות תחביר CMap שראוי להכיר

קובצי CMap נראים קלים לפענוח אך אינם כאלה, ושני פרטים אחראים לרוב כישלונות המפענחים בניסיון הראשון. הראשון הוא שספירת הרשומות מגיעה לפני מילת המפתח של הקטע: קטע ייקרא 2 beginbfchar, ולא beginbfchar 2. מפענח המצפה לספירה לאחר מילת המפתח יצרוך את המספר כאסימון (token) תועה, ואז ימצא אפס רשומות בכל קטע. הגישה החסונה — זו שקורא ה-HotPDF אימץ — היא להתעלם מהספירה לחלוטין ולבצע לולאה עד למילת המפתח התואמת endbfchar / endbfrange, מה שמאפשר גם לסבול קבצים מהעולם האמיתי שבהם הספירות פשוט שגויות

המלכודת השנייה היא שיעדי bfchar ו-bfrange הם מחרוזות UTF-16BE, ולא מספרים שלמים. היעד <D83DDE00> פירושו U+1F600 — זוג פונדקאי (surrogate pair) שיש לשלב מחדש לנקודת קוד אחת — וקריאת ארבעת הבתים האלה כמספר שלם מסוג big-endian מייצרת ערך חסר משמעות עבור כל נקודת קוד מחוץ למישור הרב-לשוני הבסיסי (Basic Multilingual Plane). אימוג'ים בקובצי PDF אינם עוד דבר נדיר, ולכן מפענח המדלג על שילוב זוגות פונדקאים ייכשל בקבצים שהמשתמשים שלכם מחזיקים בפועל. HotPDF מפענח תחילה את המחרוזת ההקסדצימלית לבתים גולמיים, ולאחר מכן משלב מחדש יחידות קוד UTF-16BE, מה שמכסה גם יעדים מרובי תווים שמיפוי ליגטורות מייצר

ירידה לרמת הגליפים באמצעות ExtractLoadedPageGlyphs

שתי קריאות הטקסט בנויות על גבי ExtractLoadedPageGlyphs, והמערך THPDFGlyphArray שמתחתיהן זמין גם לקוד שלכם. כל THPDFGlyphRecord נושא את נקודת הקוד המפוענחת של Unicode לצד קוד התו הגולמי, רוחב הבתים של הקוד (1, 2 או 4, הנקבע לפי codespacerange של ה-CMap), מפתח וגודל משאב הגופן הפעיל, נקודת המוצא X ו-Y במרחב המשתמש, והתקדמות אופקית. זה מספיק כדי לבנות זיהוי גבולות מילים, הדגשה ממוקמת או אלגוריתם פריסה מותאם אישית מבלי לגעת בזרם התוכן בעצמכם

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

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

אילו קובצי PDF לא יסכימו למסור את הטקסט שלהם?

קבצים מסוימים מכניעים כל כלי חילוץ, ועדיף לזהות אותם מאשר להעביר את הפלט שלהם הלאה. מסמכים סרוקים הם המקרה הברור ביותר: עמוד שהוא תמונה אחת גדולה אינו מכיל אופרטורים של טקסט כלל, ולכן החילוץ מחזיר בצדק מחרוזת ריקה — התיקון הוא OCR, וחילוץ תמונות העמוד מתוך ה-PDF הטעון הוא השלב הראשון בצינור עיבוד זה. גופני תת-קבוצה ללא טבלת /ToUnicode הם המקרה הקשה יותר: אם נתיב ה-/Encoding וה-CMaps הסטנדרטיים חוזרים ריקים אף הם, גליפים אלה מפענחים ל-0 ומופיעים כרווחים בקריאות הטקסט. מסמכים מוצפנים מחולצים כרגיל בתנאי שתטענו אותם עם הסיסמה שלהם באמצעות הטעינה הייעודית של LoadFromFile, כך שהזרמים מופענחים לפני שהמפרש רואה אותם

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

חילוץ טקסט, רינדור משמר פריסה, גישה ברמת הגליפים ותכונות החיפוש וההחלפה הבנויות עליהן הם כולם חלק מ-רכיב HotPDF הסטנדרטי עבור דלפי ו-C++Builder — ללא קובצי DLL חיצוניים, ללא שירותי טקסט של מערכת ההפעלה, פשוט Object Pascal שתוכלו לעקוב אחריו בצעדים כאשר קובץ מוזר נוחת בתור שלכם