חילוץ הטקסט של דף הוא החצי הקל של הבעיה. ברגע שמשתמש מקליד מילה בשדה חיפוש ומצפה שהמציג יקפוץ אליה ויצייר סביבה מסגרת צהובה, צריך משהו שמחרוזת טקסט שטוחה לא יכולה לתת לך: באיזה דף נמצא כל מופע, ואיזו תיבה הוא תופס בקואורדינטות PDF. מחרוזת שחוברה לאורך דף איבדה את הגאומטריה הזו. אפשר למצוא את תת המחרוזת, אבל אי אפשר להצביע עליה.
PDFlibPas היא ספריית PDF מקורית ב-Object Pascal ל-Delphi ול-C++Builder, ומגרסה v3.78.0 היא עונה בדיוק על השאלה הזו. שלושה ממשקי שאילתה יושבים מעל מחלץ בלוקי הטקסט הקיים: SearchText סורק טווח דפים ומחזיר כל התאמה עם הדף שלה והמלבן הצירי שלה, EnumPageElements מונה את כל מה שנמצא בדף אחד, גם בלוקי טקסט וגם תמונות מוטמעות, ו-GetTextInAreaEx מדווח על המלבן של כל בלוק בתוך אזור במקום לשטח אותם לרשימת מחרוזות. אף אחד מהם לא נוגע בנתיב הכתיבה; אלה תוספות טהורות בצד הקריאה מעל מנגנון שכבר היה בספרייה.
למה הגאומטריה נשמרת ברשימת בלוקי הטקסט ולא בצינור החילוץ
הנטייה הטבעית היא להשתמש במה ש-GetPageText מפעילה פנימית. הנתיב הזה עובר דרך צינור חילוץ זמני שמייצר את מחרוזת הדף ואז משחרר את עצמו לפני שהקריאה חוזרת. ברגע שיש לך את התוצאה, הקואורדינטות של כל בלוק כבר אינן שם. הן מעולם לא נועדו להישמר אצלך.
הקואורדינטות כן נשמרות במבנה אחר. ExtractPageTextBlocks(3) מחזירה ידית לרשימת בלוקי טקסט, שבה לכל פריט מצורפת רביעיית גבול של שמונה ערכי Double, שם גופן, גודל גופן, וטקסט הבלוק. הידית הזו היא המקום היחיד שבו הגאומטריה נשמרת אחרי החילוץ, ולכן כל אחד מממשקי השאילתה החדשים בנוי עליה ולא על הצינור. שימוש חוזר ברשימת הבלוקים אומר שחיפוש, מיפוי ושאילתות אזור חולקים כולם מעבר חילוץ אחד והגדרה אחת למיקום של בלוק.
לכן המבנה של SearchText נובע מהאילוץ הזה. עבור כל דף בטווח הוא מחלץ את רשימת הבלוקים, קורא את הטקסט של כל בלוק עם GetTextBlockText, בודק אותו מול השאילתה, ולבלוקים שתואמים הוא מצמצם את הרביעייה למלבן. ההתאמה שהוא מחזיר היא רשומה קטנה:
type
TPDFlibSearchHit = record
Page: Integer; // 1-based page of the match
Left, Top, Right, Bottom: Double; // axis-aligned hit rectangle
MatchText: WideString; // the block text that contained the query
end;
מערך הגבול משולב X/Y, לא ארבע פינות
זה הפרט הראשון שנתקלים בו. GetTextBlockBound(ListID, Index, BoundIndex) מקבל BoundIndex מ-1 עד 8, ושמונת הערכים האלה אינם "פינה 1, פינה 2, פינה 3, פינה 4" עם שני שדות בכל קבוצה כפי שאפשר היה לנחש. הם X, Y, X, Y, X, Y, X, Y: האינדקסים האי זוגיים הם קואורדינטות X, והזוגיים הם קואורדינטות Y, ארבע נקודות בסך הכול. אם קוראים אותם בזיווג הלא נכון, המלבן יוצא חסר היגיון.
הסיבה שיש בכלל רביעייה במקום מלבן פשוט היא סיבוב. בלוק טקסט שממוקם בזווית יוצר מצולע גבול אמיתי בעל ארבע נקודות, ושמונת ערכי ה-Double מתארים אותו נאמנה. לשימוש של הדגשה וקפיצה בדרך כלל רוצים במקום זאת תיבה ישרה, ולכן הספרייה מצמצמת את הרביעייה למלבן מיושר לצירים על ידי סריקה של ארבע הנקודות למציאת ערכי X ו-Y המינימליים והמקסימליים. טקסט מסובב קורס לתיבה הישרה שמקיפה אותו, וזה בדיוק מה ששכבת ההדגשה צריכה:
var
Pdf: TPDFlib;
Hits: array[0..255] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('contract.pdf', '');
// Search pages 1 to 10, case-insensitive, substring match.
Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
[Hits[I].Page, Hits[I].Left, Hits[I].Top,
Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
finally
Pdf.Free;
end;
end;
שימו לב שהמלבן נמצא בנקודות מרחב המשתמש של PDF, עם ראשית הצירים בפינה השמאלית התחתונה של הדף, באותה מערכת קואורדינטות שבה משתמשים בקריאות ציור והערות. זה מכוון: המלבן שמוחזר מהתאמת חיפוש הוא המלבן שאפשר להעביר ישירות להערת הדגשה או לפקודת "גלול לכאן" בלי לבצע שום המרה.
רגישות לרישיות, מילים שלמות, ואיפה CJK שונה
הפרמטר השני הוא סט TPDFlibSearchOptions שנבנה מתוך soCaseSensitive ו-soWholeWord. הסט הריק [] הוא המקרה הנפוץ: חיפוש תת מחרוזת שאינו תלוי ברישיות. הוסף soCaseSensitive כדי להבחין בין Indemnity ל-indemnity, הוסף soWholeWord כדי למנוע מ-sign להתאים בתוך signature, או שלב את שניהם.
התאמת מילה שלמה דורשת הגדרה של מהו גבול מילה, וכאן כדאי לומר את הכלל במפורש כי הוא מבוסס ASCII מטבעו. תו נחשב לחלק ממילה כשהוא אות ASCII, ספרת ASCII, או קו תחתון: המחלקה [A-Za-z0-9_] המוכרת מכללי מזהים. התאמה נחשבת למילה שלמה רק כשהתווים מיד לפני ואחרי ההופעה אינם תווי מילה, או כשההתאמה יושבת על קצה הבלוק.
ההשלכה עבור תסריטים שאינם לטיניים היא משהו שכדאי להכיר לפני שמשיקים תיבת חיפוש רב לשונית. מאחר שתווי Han, תווי kana ותווים אחרים שאינם ASCII נופלים מחוץ למחלקה הזו, כל גבול לידם נתפס כקצה שאינו שייך למילה. בפועל זה אומר שחיפוש מילה שלמה על טקסט CJK מתנהג כאילו כל מיקום הוא גבול מילה תקף, ולכן הדגל למעשה מדרדר שם לחיפוש תת מחרוזת. זו מגבלה מתועדת, לא באג, והיא תואמת להתנהגות שעליה התכונה עוצבה. אם הקורפוס שלך הוא בעיקר CJK, מצב מילה שלמה לא יספק את הפירוק שטוקנייזר ייעודי היה נותן; צריך לתכנן בהתאם ולא להסתמך עליו.
הערת יישום אחת שמסבירה סוג של כשלונות עדינים במקומות אחרים: ההשוואה שאינה תלויה ברישיות משתמשת ב-UpperCase על ה-WideString, ולא ב-AnsiUpperCase. הגרסה Ansi מחזירה AnsiString, שאינו מתיישב עם ה-WideString שבו משתמש שאר הנתיב, ושילוב של השניים יוצר חוסר התאמה בין טיפוסים, ובמקרים גרועים יותר גם קיפול אובדני לתווים מחוץ לדף הקוד הפעיל. Unicode נכנס, Unicode יוצא, לכל אורך הדרך.
מנתח טווח דפים אחיד לכל הספרייה
הפרמטר השלישי הוא מחרוזת טווח דפים כגון "1,3,5-9". אין שום דבר ייחודי באופן שבו היא מפוענחת: אותו PLParsePageRangeList שמשרת את PrintPages ואת שגרות העתקת הדפים מטפל בכך כאן גם כן, כך שטווח שמודפס כראוי גם נבדק כראוי. מחרוזת טווח ריקה היא הסימן ל"כל דף", ובמקרה כזה SearchText בונה את הרשימה המלאה בעצמה.
לטווח יש משמעות גם מבחינת עלות. חיפוש בקטע בן עשרה דפים מתוך מסמך בן אלף דפים מחלץ בלוקים עבור עשרה דפים, לא עבור אלף, כי הלולאה בוחרת ומחלצת רק את הדפים שהטווח מציין. אם אתה כבר יודע שסעיף מסוים יושב בנספח, ציין זאת בטווח ותחסוך את שאר הקובץ.
מבחינה פנימית, חיפוש ומיפוי גם יחד מחליפים את הדף הנבחר בזמן שהם עוברים עליו, ולכן כל אחד מהם שומר את הדף הנבחר של הקורא בכניסה ומחזיר אותו בבלוק finally. אם תקרא ל-SearchText באמצע בניית דף, הבחירה שלך תחזור בדיוק למקום שבו השארת אותה כשהקריאה תחזור. חוזה השמירה וההחזרה הזה הוא מסוג הדברים ששמים לב אליהם רק כשהם חסרים, ודווקא בגלל זה הוא שם.
מיפוי דף שלם: טקסט ותמונות ברשימה אחת
SearchText עונה על השאלה "איפה המילה הזו". החצי השני של האינטראקציה הוא "מה יש בכלל בדף הזה", וזה התפקיד של EnumPageElements. היא מחזירה רשימה מאוחדת אחת שבה כל פריט הוא או בלוק טקסט או תמונה מוטמעת, כשההבדל ביניהם מסומן בשדה Kind:
type
TPDFlibPageElementKind = (ekText, ekImage);
TPDFlibPageElement = record
Kind: TPDFlibPageElementKind;
Page: Integer;
Left, Top, Right, Bottom: Double;
Text: WideString; // ekText
FontName: WideString; // ekText
FontSize: Double; // ekText
ImageID: Integer; // ekImage; usable with SelectImage / GetImageID
end;
פריטי הטקסט מגיעים מאותו מעבר ExtractPageTextBlocks, ולכן כל אחד מהם מגיע כבר עם המלבן שלו, שם הגופן והגודל שלו מלאים. פריטי תמונה מגיעים מרשימת התמונות המוטמעות של הדף דרך FindImages ו-GetImageID; ה-ImageID שהם נושאים הוא הידית שמעבירים ל-SelectImage כדי לבדוק את התמונה לעומק. שני הסוגים נוחתים באותו מערך, כך שמעבר יחיד על דף רואה את הכול.
var
Pdf: TPDFlib;
Elems: array[0..511] of TPDFlibPageElement;
Total, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('report.pdf', '');
Total := Pdf.EnumPageElements(1, Elems);
for I := 0 to Total - 1 do
if I <= High(Elems) then
if Elems[I].Kind = ekText then
WriteLn(Format('text %s/%.1f "%s"',
[Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
else
WriteLn(Format('image id=%d', [Elems[I].ImageID]));
finally
Pdf.Free;
end;
end;
יש כאן מוסכמת ספירה שממשיכה את שאר הספרייה ושצריך לכבד אותה, אחרת תוכל לקרוא זיכרון לא מאותחל. ערך ההחזרה הוא הספירה הכוללת של הפריטים, שיכולה להיות גדולה מהמערך שהעברת. הפונקציה ממלאת רק כמה חריצים שמתאימים וממשיכה לספור את השאר, בדיוק כמו שמיפוי חתימות עובד. לכן ההגנה תמיד אותה הגנה: מגבילים את הלולאה למינימום בין הספירה המוחזרת ו-High(array), לעולם לא רצים עד הספירה בעיוורון. הדוגמאות שלמעלה מראות את הבדיקה I <= High(...) בדיוק מהסיבה הזו. אם ערך ההחזרה גדול מהבאפר שלך, צריך להגדיל את המערך ולקרוא שוב.
אם כבר השתמשת בקריאות בלוקי הטקסט ברמה נמוכה של הספרייה, זו השכבה הטיפוסית והמודעת לגאומטריה מעליהן; החילוץ הבסיסי הוא אותו אחד שמתואר ב-חילוץ טקסט, תמונות וגופנים ב-Delphi עם PDFlibPas. וכשהמטרה איננה "איפה הטקסט הזה" אלא "איך המסמך בנוי עבור טכנולוגיות מסייעות", הסיפור המקביל בצד הקריאה הוא עץ המבנה של tagged-PDF, שחושף את סדר הקריאה הלוגי במקום את פריסת הבלוקים הפיזית.
שאילתות אזור כשכבר יודעים איפה לחפש
לפעמים אין בכלל מונח חיפוש; יש לך מלבן. תבנית טופס תמיד שמה את מספר החשבונית בפינה הימנית העליונה, או שפריסה סרוקה שומרת פס קבוע לטבלה. GetTextInAreaEx משרתת את המקרה הזה. היא המקבילה עם גבולות ל-GetTextInArea: במקום להחזיר רשימת מחרוזות שטוחה עבור אזור, הקריאה החדשה מחזירה לצד הטקסט את המלבן של כל בלוק שנשמר, כך שאתה לומד לא רק מה נמצא בתיבה אלא גם איפה בתוך התיבה יושב כל שורה.
var
Pdf: TPDFlib;
Hits: array[0..63] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf', '');
Pdf.SelectPage(1);
// Left, Top, Width, Height in PDF points on the selected page.
Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Hits[I].MatchText);
finally
Pdf.Free;
end;
end;
שני דברים חשובים לזכור. GetTextInAreaEx עובדת על הדף שנבחר כרגע, לכן צריך לקרוא קודם ל-SelectPage; בניגוד ל-SearchText, היא לא מקבלת טווח. ובלוק נשמר כשהוא מצטלב עם מלבן השאילתה, לא רק כשהוא כלול בו במלואו, כך ששורה שחוצה את הגבול עדיין תיכלל. לרוב זה בדיוק מה שרוצים כשמציירים תיבת בחירה ביד, אבל אם צריך הכלה קפדנית אפשר לסנן את המלבנים המוחזרים בעצמך, כי עכשיו יש לך אותם.
להוציא את זה לפועל
החוט המקשר בין כל שלוש הקריאות הוא שהגאומטריה כבר לא משהו שמרכיבים בדיעבד. התאמת חיפוש יודעת את הדף שלה ואת התיבה שלה. פריט דף יודע את המלבן שלו, ולגבי טקסט גם את הגופן שלו. שאילתת אזור מדווחת איפה כל שורה נופלת. זה מספיק כדי לבנות יכולת אמיתית של חיפוש והדגשה, אינדקס לקפיצה בלחיצה, או מחלץ מודע לפריסה, בלי לרדת מתחת ל-API הציבורי או לבנות מחדש את צינור חילוץ הטקסט ביד.
ממשקי השאילתה האלה מסופקים כחלק מ-PDFlibPas Delphi PDF Library, יחד עם שכבת חילוץ בלוקי הטקסט המלאה שעליה הם בנויים ושאר משטח האינטראקציה בצד הקריאה ל-Delphi ול-C++Builder.