מאמר טכני

אימוג'י ותווי CJK שוברים WideChar ב-PDFium ב-Delphi

משוך אימוג'י או שם ממרשם-משפחה יפני מתוך PDF כטקסט, והפלט מציג תיבה, סימן שאלה, או כלום במקום שבו התו אמור להיות. מאפיין ה-Character[] של רכיב PDFium הוא בדרך כלל הסיבה: הוא קורא כל גליף דרך FPDFText_GetUnicode, שמחזירה נקודת קוד יוניקוד מלאה כערך בלתי-חתום בן 32 סיביות, ואז חושפת אותה ל-Delphi כ-WideChar יחיד בן 16 סיביות. כל נקודת קוד שמעבר ל-U+FFFF לא יכולה לעשות את המסע ההוא בחתיכה אחת, וההשחתה אף פעם לא מופיעה בזמן שאתה מסתכל על העמוד המעובד, משום שעיבוד וחילוץ-טקסט רצים דרך נתיבי קוד נפרדים ב-PDFium — מסמך יכול להציג את האימוג'י שלו בצורה מושלמת ועדיין למסור לך זבל ברגע שאתה קורא Character[] בלולאה ובונה מחרוזת ממנו

המישור הרב-לשוני הבסיסי ולמה WideChar נעצר ב-U+FFFF

WideChar של Delphi הוא סוג בן 16 סיביות שיכול להחזיק רק יחידת קוד UTF-16 אחת. המישור הרב-לשוני הבסיסי (BMP) של יוניקוד, הטווח U+0000 עד U+FFFF, נכנס בתוכו בדיוק, וזו הסיבה שלטינית, קירילית, יוונית, ובלוק ה-CJK Unified Ideographs הנפוץ כולם עוברים הלוך-ושוב דרך WideChar יחיד ללא אירוע. שתי משפחות תווים בשגרה נופלות מחוצה לו במסמכים אמיתיים: אימוג'י, רבים מהם בבלוק ה-Emoticons שמתחיל ב-U+1F600, ואידאוגרמות CJK נדירות מ-CJK Unified Ideographs Extension B, הטווח U+20000 עד U+2A6DF שמור עבור תווים סיניים, יפניים, וקוריאניים פחות נפוצים כולל הרבה שמות אישיים ומקומיים. ‏UTF-16 מטפל בכל דבר מעל U+FFFF עם זוג surrogate — שתי יחידות קוד בנות 16 סיביות, surrogate גבוה בטווח $D800 עד $DBFF ואחריו surrogate נמוך ב-$DC00 עד $DFFF, שביחד מקודדים נקודת קוד אחת — והחשבון שמאחורי הזיווג הזה קבוע מספיק כדי להדגים ישירות ב-Pascal

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

הזן U+1F600, אימוג'י הפרצוף-המחייך, דרך הפונקציה ההיא והתוצאה היא surrogate גבוה של $D83D ו-surrogate נמוך של $DE00, שני ערכים בני 16 סיביות, לא אחד. אף מחצית לא אומרת כלום בפני עצמה; $D83D בודד שיושב במחרוזת ללא $DE00 מאחוריו הוא surrogate תלוי (dangling), ורוב קוד טיפול-בטקסט שנתקל באחד או מפיל אותו, מחליף אותו בגליף-חלופי, או מעלה שגיאה

למה FPDFText_GetUnicode מחזירה ערך ש-Character[] לא יכול להחזיק?

FPDFText_GetUnicode מחזירה LongWord, ערך מלא בן 32 סיביות, משום שקידוד טקסט PDF כבר נושא את ערך הסקלר המלא של יוניקוד עבור כל גליף. ‏CMap מסוג ToUnicode של PDF ממפה קודי תו לטקסט יוניקוד, וכאשר גליף מייצג מה שנקרא באופן בלתי-רשמי תו-מישור-אסטרלי — כל דבר שמעבר למישור הרב-לשוני הבסיסי — המיפוי ההוא הוא נקודת קוד מלאה, לא פיסה בת 16 סיביות. ‏PDFium מפענחת אותה בחזרה לערך סקלר פנימית ומחזירה אותה על פני גבול ה-DLL דרך FPDFText_GetUnicode, והגבול הזה הוא בדיוק היכן שערך בן 32 סיביות חייב להפוך למשהו שמאפיין Delphi יכול למסור בחזרה לקוד שלך

המימוש המובן מאליו הוא WideChar(FPDFText_GetUnicode(TextPage, Index)), וזה גם הלא-נכון. ליהוק (cast) קשיח מערך בן 32 סיביות לתוך סוג בן 16 סיביות שומר רק את 16 הסיביות הנמוכות וזורק את השאר בשקט, ללא חריגה וללא בדיקת טווח. עבור U+1F600 זה אומר לשמור על $F600 ולאבד את העובדה שהערך האמיתי אי-פעם היה מעל U+FFFF, מה שמייצר יחידת קוד שאפילו לא surrogate תלוי תקף, רק תו מישור-רב-לשוני-בסיסי בלתי-קשור שבמקרה חולק את הסיביות הנמוכות ההן. שרשר כמה אלפים מאלה למחרוזת וקוד במורד-הזרם אין לו יותר דרך להבחין בין תו משוחת לתו לגיטימי

מה Character[] ו-Charcode[] מחזירים עבור נקודות-קוד מישור-אסטרלי עכשיו

מאפייני ה-Character[] וה-Charcode[] של רכיב PDFium מחזירים U+FFFD, תו-ההחלפה של יוניקוד, בכל פעם שנקודת הקוד הבסיסית חורגת מ-U+FFFF, במקום לקטום אותה בשקט. השמירה ההיא יושבת ישירות בתוך getter של המאפיין מאחורי Character[]

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

החזרת U+FFFD במקום פיסה קטומה היא תיקון מכוון, צר, ולא עיצוב-מחדש. ‏Character[] ו-Charcode[] מוקלדים כ-WideChar הן ב-TPdf והן ב-TPdfView, והרחבת סוג ההחזרה הזה כדי לשאת נקודת קוד מלאה הייתה שוברת כל קוד קורא קיים שמצפה לגליף אחד לכל אינדקס שאומר ערך אחד בן 16 סיביות. ‏U+FFFD הוא ה-placeholder המיועד של תקן היוניקוד עצמו בדיוק עבור המצב הזה, כך שקוד קורא שבודק אותו מקבל איתות מוגדר, מתועד, במקום נתונים שגויים בשקט. גבול אחד ששווה לדעת: U+FFFD הוא גם תו לגיטימי בזכות עצמו, כך שבמסמך נדיר שכבר מכיל גליף תו-החלפה אמיתי, האינדקס ההוא בלתי-ניתן-להבחנה מתו-אסטרלי-קטום לפי ערך בלבד

איך אתה מחלץ אימוג'י וטקסט CJK Extension B נכון ב-Delphi?

קרא ל-Text במקום לעבור על Character[] בכל פעם שתוכן הטקסט בפועל חשוב, משום ש-Text קוראת דרך FPDFText_GetText ומחזירה WString מלא עם זוגות surrogate נכונים עבור כל תו מישור-אסטרלי בטווח, ולא ערך ברוחב-קבוע אחד לכל אינדקס. ‏Pdf.Text(0, MaxInt), או הקיצור Pdf.Text, מחלצת עמוד שלם נכון בקריאה אחת, ו-Pdf.Text(StartIndex, Count) מושכת טווח קטן יותר באותו אופן. ‏Character[] עדיין מרוויח את מקומו כשאתה זקוק רק לנתוני מיקום, גופן, או דגל באינדקס ואף פעם לא נוגע בנקודת הקוד עצמה — CharacterOrigin[], ‏FontSize[], ו-CharacterMapError[] לא אכפת להם אם הגליף הבסיסי היה אסטרלי

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

בדיקת דלג-על-מיוצר-ולא-ממופה בלולאה ההיא היא אותה תבנית שמשמשת עבור חילוץ טקסט-פשוט בחילוץ טקסט ממסמכי PDF עם רכיב PDFium; השינוי היחיד הוא השורה האחרונה, שמחליפה צירוף Character[I] ישיר בקריאה בת-אינדקס-אחד לתוך Text כך שתווים אסטרליים מגיעים כזוגות surrogate שלמים במקום placeholders של החלפה

איפה זה בפועל נושך: ייצוא צ'אט, שמות אישיים, וגופני CJK משובצים

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

אידאוגרמות CJK נדירות גם נוטות לנסוע עם בעיות גופן, לא רק בעיות קידוד, משום שגופן חייב לשאת גליף עבור נקודת קוד בטווח U+20000 לפני שמשהו יכול להיות מעובד בכלל, ומעט גופני מערכת מותקנים עושים זאת. כל מי שכבר עובר על FontIsEmbedded[] לפי תו באופן שקריאת תכונות גופן PDF עם רכיב PDFium מתאר צריך לבדוק את אותו אינדקס עבור שתי הבעיות יחד: אינדקס שמחזיר U+FFFD מ-Character[] ומדווח גופן לא-משובץ הוא מסמך שלא יחלץ ולא ידפיס את התו ההוא נכון, והתיקון שייך במעלה-הזרם באיך שה-PDF הופק, לא בקוד החילוץ שלך

מאפייני Character[], ‏Charcode[], ו-Text המתוארים כאן הם חלק מרכיב PDFium הסטנדרטי עבור Delphi ו-C++Builder