מאמר טכני

תיקון ToUnicode: חילוץ NBSP ומקף רך מ-PDF ב-Delphi

‏PDFium Component עבור Delphi מטמיע את גופני המערכת ש-TPdf.AddText משתמש בהם בתור גופני CID שהמפתח שלהם הוא נקודת קוד Unicode, כך שכל CID נושא מיפוי ToUnicode אחד בדיוק. זה מה שמונע מרווחים שחולצו לחזור בתור U+00A0 (רווח בלתי שובר) וממקפים בתור U+00AD (מקף רך), הן מהמסמך החי והן מהקובץ השמור

התסמין מגעיל כי הוא בלתי נראה. אינדקס חיפוש מחמיץ "two-x" כי המחרוזת המאוחסנת מכילה מקף רך, ייצוא CSV מתפצל אחרת, וכלי diff מסמן שורות שנראות זהות בכל viewer. שום דבר בעמוד המרונדר אינו שגוי; רק ה-Unicode שמאחורי הגליפים

למה רווחים שחולצו חוזרים בתור U+00A0?

רווחים שחולצו מתהפכים ל-U+00A0 כי ה-CMap של ToUnicode ש-PDFium מייצר ב-FPDFText_LoadFont ערוך לפי גליף, ואל גליף אחד אפשר להגיע משתי נקודות קוד. ב-Arial, גליף 3 משרת הן את U+0020 והן את U+00A0, וגליף המקף משרת הן את U+002D והן את U+00AD. ה-CMap שנוצר ממפה אם כך את אותו CID פעמיים, פעם דרך כניסת bfchar ופעם דרך bfrange בצורת מערך, ואיזו כניסה שכלל הקדימות של הקורא מעדיף היא הטקסט שייחלץ

למה גליף Arial אחד שבר חילוץ טקסט PDF ב-Delphi: U+0020 ו-U+00A0 מגיעים לגליף 3 ו-U+002D ו-U+00AD מגיעים לגליף המקף, כך שה-ToUnicode CMap שנוצר ממפה את CID 0003 פעמיים דרך כניסת bfchar ו-bfrange של מערך, וכלל הקדימות של הקורא מכריע איזו נקודת קוד תיחלץ
כלל הזוכה-הנמוך השאיר רווחים פשוטים במשך שנים, עד שמעבר במעלה הזרם לזוכה-האחרון גרם לכל רווח של AddText להיחלץ בתור NBSP ולכל מקף בתור מקף רך
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

במשך זמן ארוך הסתירה הזאת הייתה חסרת נזק, מכיוון שהקורא של PDFium נתן למיפוי הנמוך לנצח. שינוי במעלה הזרם העביר את הקורא לזוכה-האחרון, ומה-build ההוא ואילך כל רווח שנכתב עם AddText היה נחלץ בתור NBSP וכל מקף בתור מקף רך. שימו לב לתבנית בזוגות: 0x20/0xA0 ו-0x2D/0xAD שונים רק בסיבית הגבוהה, בדיוק מה שאפשר לצפות מגופן שה-cmap שלו שולח דומי Latin-1 לאותו קו מתאר. אם קוד החילוץ שלכם היה תקין אתמול ונכשל עכשיו על תווים בלתי נראים, שפכו את נקודות הקוד במקום לסמוך על התצוגה של ה-debugger; היסודות של משיכת טקסט החוצה מכוסים במאמר על חילוץ טקסט ממסמכי PDF עם PDFium ב-Delphi

uses
  SysUtils, PDFium;

const
  // רווח/U+00A0 ומקף/U+00AD חולקים גליף Arial אחד, וכך גם
  // האומגה היוונית (U+03A9) וסימן ה-Ohm (U+2126)
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // מסמך חי, לא נשמר
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // אחרי שמירה מלאה וטעינה מחדש
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

למה תיקון ה-CMap אחרי שמירה לא הספיק

תיקון הקובץ השמור מתקן רק את הקובץ השמור, ורק אם התיקון משמר את מבנה ה-CMap שלם בייט אחר בייט. התיקון הראשון, RepairSubsetToUnicodeCMaps ביחידת FPdfCompress, רץ אחרי כל TPdf.SaveAs לא מצטבר ופותר כל CID מתנגש: כניסת ה-bfchar מנצחת, זוג ששונה רק בסיבית הגבוהה נפתר לנקודת הקוד הקטנה מבסיס ה-Latin, וכל דבר אחר שומר על המיפוי הראשון שלו

החלק המעניין הוא התוצאה השלילית. בניית ה-CMap המתנגש מחדש בצורה נקייה, בין בצורת קוד-התחלה ובין בצורת מערך, נראתה המהלך המתבקש, ו-PDFium דחה כל CMap שנבנה מחדש על הסף וחזר ל-Identity. הפלט היחיד שהקורא הילידי קיבל היה החלפה באורך שווה במקום של ערכי ה-hex המתנגשים, כשפריסת הבלוקים וכיסוי ה-CID ללא מגע. השיעור השני היה צנוע יותר: ההערה שלנו באותה עת האשימה את המקרה בזיכרון בזה שלמסמך החי אין בכלל זרם ToUnicode. קריאה ישירה ל-DLL הפריכה את זה, מכיוון שהמסמך החי נושא את אותו זרם דו-משמעי, מה שאמר שהתיקון האמיתי היה צריך לקרות לפני ש-PDFium מייצר בכלל את ה-CMap. שגרת התיקון נשארת בספרייה כהגנה עבור PDF שהופקו על ידי כלים אחרים מבוססי PDFium

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // עריכות באורך שווה בלבד; קבצים בלי התנגשות שניתנת לתיקון,
      // וקבצי זרם הפניות צולבות או זרם אובייקטים, מועתקים כמו שהם
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

מפתח הגופן לפי נקודת קוד במקום לפי גליף

התיקון השורשי הוא להפסיק לבקש מ-PDFium לייצר את ה-CMap מלכתחילה. TPdf.LoadCachedFont מוסר כעת את בייטי הגופן המערכתי ל-TPdf.LoadUnicodeKeyedCidFont, שקורא את טבלת ה-cmap של ה-sfnt של הגופן עצמו, מעדיף תת-טבלה בפורמט 12 וחוזר לפורמט 4. נקודות הקוד חוזרות ממוינות וללא כפילויות, ו-CID k+1 מוקצה לנקודת הקוד ה-k, כש-CID 0 נשאר בתור .notdef. CIDToGIDMap מפורש שולח כל CID לגליף שלו, כך ש-U+0020 ו-U+00A0 מקבלים שני CID שונים שמציירים את אותו קו מתאר, וה-ToUnicode CMap ממפה כל CID לנקודת קוד אחת בלבד. הגופן נטען אז דרך FPDFText_LoadCidType2Font, אותה נקודת כניסה שעומדת מאחורי הכתיבה ברמת גליף במאמר על הטמעת גופני CID Type 2 עם מפות CID-to-GID מפורשות

התיקון מבוסס נקודת קוד ב-PDFium Component: LoadUnicodeKeyedCidFont קורא את ה-cmap של ה-sfnt של הגופן, מקצה CID k+1 לכל נקודת קוד ממוינת כש-CID 0 הוא notdef, מחבר CIDToGIDMap מפורש כך ש-U+0020 ו-U+00A0 שומרים על CID שונים, ו-BuildUnicodeKeyedCidCMap נותן לכל CID נקודת קוד אחת בדיוק
NBSP, מקף רך וסימן ה-Ohm שורדים אז כעצמם תחת כל אחד מכללי הקדימות, במסמך החי ואחרי כל שמירה, כך שתיקון ה-CMap לא מוצא עוד מה לתקן
// מקוצר מתוך TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // CID אחד, נקודת קוד אחת
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

כש-FPDFText_SetText כותב אחר כך מחרוזת, החיפוש ההפוך נוחת על CID אחד לכל תו, כך ש-NBSP, מקף רך וסימן ה-Ohm כל אחד שורד כעצמו תחת כל אחד מכללי הקדימות, בזיכרון ואחרי כל שמירה. מכיוון שהקובץ השמור נושא את זרם ה-ToUnicode של הרכיב עצמו ולא כזה שהמנוע ייצר, RepairSubsetToUnicodeCMaps לא מוצא בו מה לתקן

למה כניסת bfrange אחת יכולה למחוק בלוק שלם?

bfrange יחיד שריצת ה-CID שלו חוצה גבול xxFF גורם ל-PDFium להשליך את הבלוק כולו שבו הוא יושב. ISO 32000-1 §9.10.3 מתיר רק לבייט האחרון של היעד להשתנות בתוך טווח, אבל לצד ה-CID יש מלכודת משלו: ה-HandleBeginBFRange של PDFium גוזר את ה-CID הגבוה בתור (low and $FFFFFF00) or (high and $FF). ריצה מ-CID 00FE עד 0101 נקראת אם כך כ-00FE עד 0001, נמוך גדול מגבוה, והבלוק כולו מסומן פסול. הכשל שקט: SetText מצליח, העמוד מרונדר בשלמות, והחילוץ מחזיר U+0000 עבור כל תו באותו בלוק

המלכודת השקטה של bfrange בפרסור CMap של PDF: ריצת CID מ-00FE עד 0101 חוצה גבול xxFF, HandleBeginBFRange גוזר את ה-CID הגבוה בתור 0001, נמוך גדול מגבוה מסמן את הבלוק כולו פסול, SetText והרינדור עדיין מצליחים, והחילוץ מחזיר U+0000 עבור כל תו בבלוק
BuildUnicodeKeyedCidCMap נמנע מהמלכודת על ידי סיום כל ריצה לפני בייט נמוך של FF, שמירת בלוקים בתוך המגבלה של 100 כניסות וכתיבת נקודות קוד מהמישור המשלים בתור כניסות bfchar נפרדות

‏BuildUnicodeKeyedCidCMap מסיים ריצה לפני שהנקודה הקוד או ה-CID מגיעים לבייט נמוך של FF, שומר על כל בלוק בתוך מגבלת 100 הכניסות של דקדוק ה-CMap, וכותב נקודות קוד מהמישור המשלים בתור כניסות bfchar נפרדות עם יעדי זוגות סורוגט של UTF-16, מכיוון שהגדלת זוג סורוגט בתוך טווח אינה בעלת משמעות מוגדרת; הצד הסורוגט של הסיפור הזה במאמר על טיפול ב-emoji, CJK וזוגות סורוגט ב-Delphi. CMap של bfchar בלבד היה עוקף את בעיית הגבול לחלוטין, במחיר של כמה פעמים הגודל

מה הנתיב המבוסס נקודת קוד לא מכסה?

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

  • גופני Symbol עם cmap של (3,0) בלבד, וכל גופן שהנתיב של ה-CID נכשל לטעון, עוברים דרך FPDFText_LoadFont כמו קודם, כך שגליף משותף לשתי נקודות קוד עדיין יכול להיחלט שם באופן דו-משמעי
  • בלי תת-טבלה בפורמט 12 המפה מוגבלת ל-BMP, ומספר הכניסות מוגבל ל-65535 כדי שכל CID ייכנס בשני בייטים מעל אפס
  • שמירות מצטברות (saIncremental) מדלגות על RepairSubsetToUnicodeCMaps במכוון, כי מהדורה מצטברת חייבת להישאר בתוספת-בלבד; הגופנים המבוססים נקודת קוד הופכים את זה לחסר חשיבות עבור טקסט שהרכיב כותב בעצמו
  • אוספי TrueType Collection זקוקים לזהירות נוספת: ה-GetFontData של GDI מחזיר את כל ה-.ttc, ול-FPDFText_LoadCidType2Font אין פרמטר אינדקס פנים, כך שבקשת NSimSun מ-simsun.ttc הטמיעה ורנדרה פעם את SimSun, הפנים 0. הרכיב מתאים כעת את שם המשפחה מול טבלת השמות (nameID 1 ו-16) וחולץ את הפנים המבוקשות בתור sfnt עצמאי לפני שה-cmap נפרס; אם הפרסור נכשל, בייטי האוסף עוברים כמו שהם וההתנהגות חוזרת לפנים 0

כתיבת טקסט, הטמעת גופנים וחילוץ חולקים מודל עמוד אחד בין Delphi, C++Builder ו-Lazarus, וה-API המלא מתואר בעמוד המוצר של PDFium Component עבור Delphi