מאמר טכני

חיפוש והחלפת טקסט ב-PDF קיים באמצעות דלפי

רכיב HotPDF יכול לחפש ולהחליף טקסט בתוך קובץ PDF קיים מתוך דלפי ו-C++Builder. הפונקציות SearchLoadedPageText ו-SearchLoadedDocumentText מאתרות כל מופע של מחרוזת בדיוק ברמת הגליף, ו-ReplaceLoadedPageText ו-ReplaceLoadedDocumentText משכתבות את הבתים המתאימים במקומם — בתנאי שכל תו חלופי ניתן לקידוד מחדש דרך הגופן המקורי, מגבלה פיזית שמאמר זה מתייחס אליה בכנות ולא מחביא בהערת שוליים

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

מדוע החלפת טקסט ב-PDF כל כך קשה?

החלפת טקסט ב-PDF היא קשה מכיוון שעמוד PDF אינו מכיל טקסט שניתן לעריכה — הוא מכיל גליפים ממוקמים. תחת מודל הצגת הטקסט של תקן ISO 32000-1 §9.4, זרם תוכן מפעיל אופרטורים כמו Tj ו-TJ המציירים רצפים של קודי תווים בקואורדינטות שנקבעו על ידי מטריצת הטקסט. קודים אלה אינם Unicode; הם אינדקסים לתוך הקידוד שגופן העמוד מצהיר עליו, והמיפוי בחזרה לתווים קריאים עשוי להימצא ב-CMap של /ToUnicode, במערך הבדלי קידוד או בשרשרת מיפוי CID. אין אובייקט פסקה, אין זרימת טקסט, ואין ערובה לכך שמילה ויזואלית אחת מאוחסנת בכלל כמחרוזת אחת

ההחלפה מוסיפה שכבת קושי שנייה מעבר לפענוח: עליכם לדעת בדיוק אילו בתים בזרם המקורי ייצרו כל גליף, כדי שתוכלו לחבר בתים חדשים בדיוק לתוך הטווח הזה ולא לשום מקום אחר. כלי לחילוץ טקסט יכול להרשות לעצמו לזרוק את מיקומי הבתים ברגע שהוציא את ה-Unicode. כלי החלפה אינו יכול לעשות זאת. זו הסיבה ש-HotPDF פיצל את העבודה על פני שתי גרסאות — גרסה v2.251.0 בנתה את שכבת מעקב ההיסט והחיפוש, וגרסה v2.252.0 בנתה את שכבת השכתוב מעליה

מציאת טקסט: חיפוש ברמת הגליפים עם מעקב אחר היסטי בתים

הפונקציה SearchLoadedDocumentText של HotPDF מוצאת כל מופע של מחרוזת חיפוש על ידי התאמה מול רצף גליפי ה-Unicode המפוענחים של כל עמוד, ולא מול בתים גולמיים של הזרם, כך שהתאמה היא התאמה ללא קשר לאופן שבו הגופן קידד אותה. התשתית שמתחת הוצגה בגרסה v2.251.0: מנתח האסימונים (tokenizer) של זרם התוכן מתעד טווח בתים של StartOfs/EndOfs לכל אופרנד מחרוזת — כולל התוחמים ( ) או < > שלו — וכל גליף מפוענח נושא שלישיית TokenIndex/ItemIndex/ByteOffset המצביעה בחזרה לאופרנד המדויק, לפריט במערך ה-TJ, וליחידת הקוד שייצרה אותו. אותו מפרש גליפים מניע את ממשק ה-API לחילוץ המתואר בחילוץ טקסט מ-PDF טעון בדלפי; החיפוש פשוט שומר על המקור שהחילוץ משליך

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

var
  Pdf: THotPDF;
  Matches: THPDFTextMatchArray;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
    begin
      if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
        for I := 0 to Length(Matches) - 1 do
          WriteLn(Format('page %d at (%.1f, %.1f): "%s"',
            [Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
             Matches[I].Text]));
    end;
  finally
    Pdf.Free;
  end;
end;

החלטת עיצוב מכוונת אחת ראויה לציון. כאשר CaseSensitive מוגדר כ-False, ההשוואה מתעלמת מרגישות לאותיות רישיות עבור תווי ASCII בלבד, לפי תכנון: התעלמות מלאה מרגישות לאותיות ב-Unicode מתנהגת באופן שונה לאורך גרסאות Delphi 5 עד XE שבהן HotPDF תומך, וממשק API לחיפוש המוצא התאמות שונות בהתאם למהדר שבנה את האפליקציה שלכם גרוע מכזה בעל מגבלה מתועדת וצפויה. עבור טקסט עסקי לטיני — שמות, קודים, תאריכים — קיפול ASCII מכסה את המקרים המעשיים

החלפת טקסט: קידוד הפוך וחיבור כירורגי

ReplaceLoadedDocumentText, שנוספה בגרסה HotPDF v2.252.0, משכתבת כל מופע של מחרוזת החיפוש על ידי הרצת מנגנון הפענוח לאחור. הפונקציה HPDFEncodeUnicode היא ההופכית של מפענח קודי התווים: היא עוברת על אותה שרשרת אסטרטגיות בסדר הפוך — חיפוש ב-bfchar ו-bfrange של /ToUnicode, מיפוי CID בזרם הקידוד, מיפוי זהות (identity) של Type0, וטבלאות WinAnsi ו-MacRoman המוגדרות מראש — כדי להפוך כל תו חלופי בחזרה לבתים של קוד התו שהגופן המקורי מצפה להם. הבתים המקודדים מחדש מיוצגים לאחר מכן כמחרוזת טקסט תקינה או כמחרוזת הקסדצימלית, תוך שימוש באותם חוקי מילוט של מנתח האסימונים, כך שסבב מלא של ניתוח ← ייצוג מחדש נשאר יציב

החיבור עצמו הוא כירורגי ולא סיטונאי. רק טווח בתי הקוד המכוסה על ידי ההתאמה מוחלף בתוך אופרנד המחרוזת; בתים שלא תאמו באותו אופרנד, הרווחים הלבנים בין אסימונים וכל אופרטור מסביב נשמרים כלשונם, בית אחר בית. החלפת bca בתוך abcabc מניבה a + מחליף + bc, ולא אופרנד הרוס. ההחלפות עשויות להיות קצרות או ארוכות יותר ממחרוזת החיפוש — המחרוזת מיוצגת מחדש וערך ה-/Length של הזרם מתעדכן — וכל זרם /Contents בעמוד בעל מספר זרמים מעובד בנפרד כדי שהעמוד יישאר תקין

var
  Pdf: THotPDF;
  ReplaceCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
    begin
      if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
        True, ReplaceCount) then
        WriteLn(Format('%d operand rewrites performed', [ReplaceCount]));
      Pdf.SaveLoadedDocument('contract-final.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

שימו לב מה ה-API אינו עושה: הוא אינו מעמד מחדש את העמוד (no re-typesetting). לקובצי PDF אין זרימה מחדש (reflow), כך שהחלפה שהיא רחבה ויזואלית מהמקור פשוט תתפוס יותר מקום אופקי ועלולה להצטופף מול כל מה שצויר מימינה. החלפות באותו אורך או באורך קרוב — תאריכים, מחרוזות גרסה, מספרי חלקים, תיקוני שמות — הן נקודת האיזון המושלמת. ניסוח מחדש סיטונאי שייך למסמך המקור, לא ל-PDF

מדוע אי אפשר להחליף טקסט בתווים שתת-הקבוצה של הגופן לא כללה מעולם?

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

HotPDF מטפל בכשל זה בצורה שמרנית. אם תו בודד של ההחלפה אינו ניתן לקידוד מחדש, כל אותו מופע של מחרוזת החיפוש מדולג — ללא חריגה, ללא טקסט חלקי משובש, והמופע פשוט אינו נספר ב-ReplaceCount. המשמעות המעשית: השוו את ReplaceCount מול ספירת ההתאמות מחיפוש קודם, והתייחסו לחוסר כאל סימן. בדוגמת התאריך לעיל, הספרה 6 חייבת להופיע במקום כלשהו בטקסט של המסמך באותו גופן כדי שהשכתוב יצליח — סביר להניח בחשבונית, אך מעולם לא מובטח באופן כללי. כאשר התווים הדרושים לכם פשוט אינם זמינים והמטרה היא להסיר טקסט רגיש במקום לנסח אותו מחדש, הסרת תוכן אמיתית היא הכלי הטוב יותר בכל מקרה; ראו השחרה ובנייה מחדש של קובצי PDF טעונים בדלפי לנתיב זה

var
  Matches: THPDFTextMatchArray;
  Expected, Replaced: Integer;
begin
  Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
  Expected := Length(Matches);
  Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
  if Replaced < Expected then
    WriteLn(Format('%d occurrence(s) skipped: characters missing ' +
      'from the font subset, or match spans multiple operands',
      [Expected - Replaced]));
end;

תנאי הדילוג השני בהודעה זו הוא הגבול המתועד השני: מחרוזת חיפוש המשתרעת על פני מספר אופרנדים של מחרוזות — Hello המפוצל על פני פריטי [(He)(llo)] TJ, למשל — תימצא על ידי החיפוש, מכיוון שהחיפוש מתאים לרצף הגליפים המפוענח, אך תדולג על ידי ההחלפה, מכיוון ששכתוב על פני גבולות אופרנדים ידרוש מיזוג של טווחי בתים סמוכים. חיפוש ולאחריו אימות הופכים את שני הגבולות הללו לגלויים במקום לשקטים

מה משתנה בקובץ בזמן השמירה?

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

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

חיפוש והחלפת טקסט מצטרפים לחילוץ, השחרה ורינדור עמודים בארגז הכלים של מסמכים טעונים ב-HotPDF, כולם מונעים על ידי אותו מפרש זרם תוכן וזמינים מ-Delphi 5 ועד לגרסאות RAD Studio הנוכחיות ללא תלות חיצונית. רפרנס ה-API המלא והורדת גרסת הניסיון נמצאים בדף המוצר רכיב HotPDF