מאמר טכני

מיפוי טקסט PDF לבתים בזרם התוכן ב-Delphi

תו שגוי אחד במספר חשבונית, והפרימיטיב היחיד לעריכה הוא שכתוב של כל רצף הטקסט. PDF Library for Delphi סוגרת את הפער הזה: GetTextBlockCharContentLocation ממפה כל מיקום UTF-16 שחולץ חזרה להוראת זרם התוכן, לאופרנד ולטווח הבתים המקודד שיצר אותו, ו-ReplaceTextBlockCharSourceBytes דורס רק את הטווח הזה. חילוץ טקסט בדרך כלל משליך את כל מה שהייתם צריכים לכך. מקבלים Unicode, רוחבים וגיאומטריה, והמקוריות נעלמת, כך שהתוו שבמיקום 7 של בלוק 3 הוא פשוט תו. איזה זרם יצר אותו, איזו הוראה, איזה אופרנד, איזה בית בתוך אותו אופרנד: הכול נעלם. כל אסטרטגיית עריכה נקודתית שנבנית מעל זה צריכה לנחש, בדרך כלל באמצעות חיפוש תת-מחרוזת מפוענחת בתוכן וקיווה שהיא מופיעה בדיוק פעם אחת. בעמוד אמיתי היא לא

מדוע שכתוב של רצף טקסט שלם הורס את העמוד

מפני שהרצף אינו רק טקסט. אופרטורי הצגת הטקסט ב-ISO 32000-1 §9.4.3 כוללים את TJ, שהאופרנד שלו הוא מערך המשלב מחרוזות עם התאמות מספריות, והמספרים האלה הם הטיפוגרפיה. שורה שנפרסה כ-[(AB) -120 (CD)] TJ נושאת kerning של 120 אלפיות em בין שתי המחרוזות. פליטה של Tj חדש עם הטקסט המאוחד מאבדת את ה-kerning, השורה נפרסת מחדש בשבריר, ובטופס הערך זז מחוץ לתיבה שלו. אותה התנגדות חלה על הגופן: בתי האופרנד הם קודים בכל קידוד ש-Tf בחר, לא Unicode, ובגופן מורכב הם עשויים להיות CIDs של שני בתים ללא קשר לתו שקראתם מהמחלץ. יצירת הרצף מחדש מחייבת לדייק בקידוד הגופן, במפת /ToUnicode ובכיסוי הגליפים שלו. עריכה נקודתית עוקפת את כל זה בכך שאינה עוזבת לעולם את תחום הבתים

מה מחזירה GetTextBlockCharContentLocation

המתודה פותרת תו אחד לרשומה בת תשעה שדות, וכל שדה הוא כתובת ולא ערך. ContentLayer הוא האינדקס המבוסס על 1 במערך /Contents של העמוד, או 0 כאשר התו הגיע מתוכן מקונן. StreamObjectNumber ו-StreamGeneration מזהים את הזרם המכיל. InstructionIndex הוא המיקום המבוסס על 0 בתוכנית התוכן המפוענחת, OperandIndex הוא אופרנד מחרוזת הטקסט ו-ArrayElementIndex הוא האיבר בתוך מערך TJ או -1 עבור אופרנד מחרוזת ישירה. לאחר מכן SourceByteOffset ו-SourceByteLength נותנים את טווח הבתים בתוך אותה מחרוזת מפוענחת

Var
  Lib: TPDFlib;
  ListID, Block, CharPos: Integer;
  ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
  InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
  SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    Lib.LoadFromFile('invoice.pdf', '');
    Lib.SelectPage(1);
    ListID:= Lib.ExtractPageTextBlocks(3);
    Try
      // Block ו-CharPos מגיעים מסריקה משלכם של GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 אומר שהגליף חי ב-Form XObject מקונן
        // ArrayElementIndex = -1 אומר אופרנד Tj רגיל, לא מערך TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

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

עריכת בתים, לא Unicode

ReplaceTextBlockCharSourceBytes מקבלת AnsiString של בתי החלפה גולמיים בקידוד הגופן הפעיל של PDF. זהו כל התכנון, והוא מכוון. שום דבר אינו עושה transcode, שום דבר אינו מקודד מחדש ושום דבר אינו מנחש את הגופן. הספרייה משלבת את הבתים שלכם על הטווח הנקוב של מחרוזת היעד ופולטת מחדש את שכבת התוכן המכילה. מחרוזות סמוכות באותו מערך TJ וה-kerning המספרי שביניהן נשארים זהים בייט-לבייט. קחו את הפריסה שלמעלה: מציאת ה-B בתוך [(AB) -120 (CD)] TJ מחזירה ArrayElementIndex 0, SourceByteOffset 1, SourceByteLength 1. החליפו אותו ב-Z והתוכן הנפלט מכיל (AZ), עדיין ואחריו -120 ו-(CD), שניהם ללא שינוי. חבילת הרגרסיה טוענת בדיוק זאת, מפני שהטענה "שמרנו על ה-kerning" היא מסוג הטענות שמפסיקות להיות נכונות בשקט

Function EditableHere(Flags: Integer): Boolean;
Begin
  Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
    ((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
      PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
      PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;

// ...
If EditableHere(Flags) Then
Begin
  If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
  Begin
    // כל המיקומים ברשימה הישנה נעשו stale. יש לבצע חילוץ מחדש
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // השכבה השתנתה מתחתינו מאז החילוץ
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // דגל שלא בדקנו, או דגל שנוסף בגרסה מאוחרת יותר
End;

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

אילו תווים אי אפשר לערוך

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

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: כמה מיקומי UTF-16 שחולצו מתרחבים מגליף מקור אחד. רשומת /ToUnicode שממפה קוד אחד ל-fi נותנת לכם שני תווים שחולקים טווח בתים אחד, לכן יש להתייחס אליהם כאל גליף מקור יחיד ולערוך את הטווח פעם אחת
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: התו נוצר במהלך הפריסה. רווחי מילים משוערים הם המקרה הרגיל, ואין להם בתי מקור כלל, לכן SourceByteOffset חוזר -1 ו-SourceByteLength 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: הטקסט שקראתם הגיע מהחלפה של /ActualText. אין מיפוי הפוך יחיד מהמחרוזת שהוחלפה לבתי המקור, לכן המיקום הוא אבחוני בלבד
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: הגליף נמצא בתוך Form XObject. הבתים ניתנים לכתובת, אך ה-Form עשוי להיצייר על ידי כמה עמודים, ולכן עריכה שלו דרך ה-API ברמה גבוהה תהיה עריכה שלא ביקשתם
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: האופרנד היה מחרוזת hex שנשאה BOM של UTF-16BE, ונתיב החילוץ הקיים פענח אותה לפני מיפוי הגופן. Offsets לתוצאה המפוענחת כבר אינם מצביעים על הבתים המקוריים, ולכן הדגל valid מנוקה
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: אופרנד המחרוזת והאופרטור שמציג את הטקסט נמצאים בשני זרמים שונים

האחרון ראוי למשפט משלו, מפני שמפתחים מניחים בשגרה שהוא אינו יכול לקרות. ISO 32000-1 §7.8.2 אומר שהזרמים במערך /Contents של עמוד משורשרים, והחלוקה ביניהם חייבת ליפול רק בגבול לקסיקלי. לכן BT /F1 16 Tf 220 340 Td (CrossLayer) בזרם אחד ו-Tj ET בזרם הבא הם עמוד חוקי לחלוטין. המיפוי שומר את המיקום האבחוני אך מסמן אותו לקריאה בלבד, מפני שאינדקס ההוראה שייך לשכבה אחרת מזו של בתי האופרנד, ושימוש באחד כדי לכתובת את השני ישחית את הקובץ

כיצד הספרייה יודעת שהמפה עדיין תקפה

טביעות אצבע, שנבדקות מיד לפני הכתיבה. כל רשימת חילוץ מתעדת את עמוד המקור, ובכל שכבת תוכן את אורך השכבה ושני hashes מתגלגלים בלתי תלויים: hash של FNV-1a ו-hash בסגנון XOR של DJB2. לפני ש-ReplaceTextBlockCharSourceBytes מפענחת משהו היא קוראת מחדש את שכבת היעד ומשווה את כל שלושת הערכים. כל שינוי בייט בכל מקום בשכבה מחזיר PDFLIB_ERROR_TEXT_LOCATION_STALE והכתיבה אינה מתבצעת. זה שמרני בכוונה: הבדיקה היא לפי שכבה ולא לפי הוראה, ולכן גם עריכה שאינה קשורה במקום אחר באותו זרם תוכן מבטלת את המיקום שלכם. זו הפשרה הנכונה: offset לתוך זרם שזז אפילו בית אחד אינו כמעט-פגיעה, אלא שחיתות שקטה. אותה משמעת מנהלת את שאר משטח העריכה, כולל מעקב מצב זרם התוכן עבור CTM ו-clipping. אחרי כל החלפה מוצלחת, יש להשליך את הרשימה ולבצע חילוץ מחדש

מיפוי לקריאה בלבד דרך Direct Access

DAGetTextBlockCharContentLocation נותנת את אותה רשומה עבור עמוד שנפתח דרך נתיב Direct Access, עם אותו אוצר דגלים. היא אבחונית בלבד מטבעה: ReplaceTextBlockCharSourceBytes פועלת על מסמך העריכה שנבחר, ו-Direct Access הוא נתיב קריאה. נתוני המיקום נשארים ברשימת בלוקי הטקסט אחרי סגירת ה-handle של הקובץ, מה שהופך אותם לשימושיים לביקורת offline

FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
  PageRef:= Lib.DAFindPage(FileHandle, 1);
  DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
  Try
    Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
      ContentLayer, StreamObjectNumber, StreamGeneration,
      InstructionIndex, OperandIndex, ArrayElementIndex,
      SourceByteOffset, SourceByteLength, Flags);
    // המיקומים נשארים קריאים אחרי DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

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

היכן עריכה נקודתית נעצרת

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