מאמר טכני

מלכודות Codepage של FPC ששוברות מטא-נתוני XMP של PDF/A

רכיב PDFium ל-Delphi מרכיב את חבילת ה-XMP עבור פלט PDF/A על ידי שרשור מקטעי UTF-8 לתוך AnsiString, וב-Free Pascal 3.2.2 החבילה הפסיקה בשקט להיות UTF-8 תקף ברגע שכותרת מסמך נשאה תו שאינו ASCII. ISO 19005-1 6.7.2 דורש שזרם המטא-נתונים יהיה UTF-8 תקף, ולכן הקובץ נכשל באימות. גרסה 3.103.1 מתקנת את ה-encoder עצמו, בתוך StringToUtf8. החלק המעניין אינו ה-patch. הוא ששורת מקור אחת ללא שינוי הפיקה בתים נכונים תחת Delphi, בתים נכונים באפליקציית Lazarus LCL ובתים פגומים בתוכנית console רגילה של Free Pascal שקופלה מאותה יחידה בדיוק. שלוש התנהגויות נפרדות של מחרוזות Free Pascal חייבות להסתדר זו עם זו לפני שזה נעשה מובן, וכל אחת מהן ניתנת להגנה בפני עצמה

מדוע אותו קוד מטא-נתונים פולט בתים שונים ב-Delphi וב-FPC

מפני ש-string אינו אותו טיפוס בשני המהדרים. FPC 3.2.2 ב-{$MODE Delphi} מקמפלת string ל-AnsiString שמתויג ב-DefaultSystemCodePage, בעוד Delphi מקמפלת אותו ל-UnicodeString. כל שדה מטא-נתונים ב-TPdfASaveOptions מוצהר כ-string, לכן Title, Author, Subject, Keywords, Creator ו-Producer נושאים code units של UTF-16 במהדר אחד ותווים בני בית אחד עם תווית codepage בשני. אותה רשומה, payload שונה. הערכים עצמם מגיעים מהמסמך כ-UTF-16. TPdf.GetTitle ואחיותיה מחזירות WString, שהוא WideString ב-FPC ו-string (UnicodeString) ב-Delphi, ו-SaveAsPdfAToStream ממלאת כל שדה option ריק ממילון Info לפני הזרקת markers. ההשמה הזו היא narrowing conversion ב-Free Pascal, וה-RTL מבצעת אותה דרך ה-codepage של מחרוזת היעד. בתוכנית LCL, LazUTF8 כבר הגדירה את DefaultSystemCodePage ל-CP_UTF8, לכן ה-narrowing מייצר UTF-8 וכל מה שאחריו קורה במקרה נכון. בתוכנית console רגילה אותה narrowing נוחתת על ה-ANSI codepage, ואז StringToUtf8 מעתיקה את האוקטטים האלה ללא שינוי מפני שהניחה שהם כבר UTF-8. שישה גשרי save חולקים את הצורה הזו: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream ו-SaveAsPdfVTToStream, כל אחד עם רשומת option משלו

// PDFium.pas: accessors של המסמך תמיד ב-UTF-16
//   WString = WideString ב-FPC, = string (UnicodeString) ב-Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: רשומת אפשרויות השמירה נושאת מטא-נתונים כ-`string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString ב-Delphi
                      // AnsiString + DefaultSystemCodePage ב-FPC
  Author: string;
  Subject: string;
  Keywords: string;
  Creator: string;
  Producer: string;
  CreationDate: string;
  ModDate: string;
  DocumentId: TBytes;
  InstanceId: TBytes;
  class function Default: TPdfASaveOptions; static;
end;

// SaveAsPdfAToStream ממלא שדות ריקים ממילון Info.
// ה-narrowing נכתב כעת במפורש במקום להישאר implicit:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

ניתוב כל ששת הגשרים דרך helper יחיד של WStringToStr אינו משנה את מה שה-RTL עושה, אך מציב את ההמרה במקום שקורא יכול לראות, והוא פינה 92 אזהרות implicit-conversion שהסתירו בדיוק את סוג הבעיה הזה. זהו תמונת המראה של השחיתות מצד Delphi שמתוארת בהערות שלנו על מלכודות cross-compiler של Delphi ו-FPC בבניות PDFium, שבה שרשור ב-Delphi הורס high byte ש-Free Pascal משמרת

שלוש התנהגויות של Free Pascal שמביסות את התיקון המתבקש

התיקון המתבקש הוא לקרוא ל-UTF8Encode ולסיים. זה נכשל שלוש פעמים על FPC 3.2.2 במצב Delphi, וכל כשל שקט

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // מלכודת 1: במצב Delphi משתנה מסוג UTF8String הוא AnsiString רגיל,
  // לכן ההשמה ממירה את האוקטטים ישר בחזרה ל-codepage של ה-host
  U := UTF8Encode(W);

  // מלכודת 2: S כבר הוא AnsiString, לכן UTF8Encode אינה עושה דבר
  R := UTF8Encode(S);                 // בלי decode, בלי encode, בלי שגיאה
  R := UTF8Encode(UnicodeString(S));  // זה באמת מקודד

  // מלכודת 3: שרשור מאחד כל operand ל-codepage של היעד,
  // וגם יעד RawByteString אינו חריג
  Xmp := Xmp + R;
end;

מלכודת אחת אומרת שתוצאת ה-encode חייבת להישאר ב-AnsiString או ב-RawByteString שבה הופקה. מעבירים אותה דרך temporary של UTF8String בדרך החוצה וביטלתם את העבודה. מלכודת שתיים היא זו שמסתתרת זמן רב יותר, מפני ש-UTF8Encode(S) מתקמפלת, רצה, מחזירה ערך באורך הנכון ואינה מבצעת שום המרה כאשר הארגומנט שלה כבר AnsiString; רק הרחבה ל-UnicodeString תחילה גורמת לקריאה לפענח משהו. מלכודת שלוש היא הסיבה לכך ש-encoder נכון עדיין יכול להפיק מסמך שבור: BuildXmpBytes צוברת את החבילה ב-local בשם Xmp: AnsiString, ו-Free Pascal ממירה כל operand של שרשור ל-codepage של משתנה היעד ומקפלת את הרצפים מרובי-הבתים בחזרה לבתי ANSI בדרך פנימה

מה SetCodePage עם False באמת מבטיח

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) מתייגת מחדש את המחרוזת בלי לגעת ב-byte. הפרמטר השלישי הוא Convert; העברת False פירושה "הנח שה-payload כבר נמצא ב-codepage של היעד ורק שנה את התגית". זו שקר לגבי התוכן שנאמר בכוונה: האוקטטים באמת UTF-8, אך תיוגם כ-codepage של ה-host הוא מה שעוצר את השרשור במלכודת שלוש מלהמיר אותם. הם מצטרפים ל-buffer של XMP כבתים גולמיים ויוצאים מעברו ללא שינוי

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode כבר מחזירה אוקטטים המתויגים CP_UTF8,
  // ושרשור ל-AnsiString משמר אותם
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: הרחב תחילה, אחרת UTF8Encode היא no-op על ארגומנט AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // תייג מחדש בלי transcoding, כדי שהאוקטטים ישרדו שרשור
  // ל-buffers עם תג ANSI שמרכיבים חבילות XMP ואובייקטי PDF מסוג string
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

היו ברורים לגבי הגבול. ה-retag הוא FPC-only ואינו ברכה כללית לערבוב מחרוזות מתויגות ולא מתויגות. הוא עובד כאן מפני שקיים בדיוק דפוס צרכן אחד downstream: append ל-AnsiString ואז כתיבת ה-buffer כבתים. כל דבר שינסה לפרש את הערך שעבר retag כטקסט ב-codepage של ה-host יקרא mojibake, ובצדק. הכיוון ההפוך מטופל באופן אחר וזהה בשני המהדרים: מתייגים את ה-buffer הנכנס כ-CP_UTF8 עם SetCodePage(..., False) ואז קוראים ל-UTF8ToString

מדוע גם בדיקות הרגרסיה נשאו את אותה מלכודת

מפני שבדיקה שבונה את הבתים הצפויים מ-literal של המקור בודקת את המהדר ולא את הספרייה. קבוע כגון #$C3#$A9 בקובץ מקור של Pascal נושא את ה-codepage בזמן הקומפילציה של אותו קובץ, וכאשר הוא מועבר לפרמטר AnsiString ה-RTL מקודדת אותו מחדש, וזה בדיוק ה-conversion שבבדיקה. את הציפייה חייבים להרכיב בזמן ריצה, byte אחרי byte, ולהשוות byte אחרי byte, מפני ש-= על שני ערכי AnsiString עם tags שונים מיישב את ה-codepages לפני ההשוואה ומחזיר false negative עליז

function BytesPattern(const Values: array of Byte): AnsiString;
var
  I: Integer;
begin
  SetLength(Result, Length(Values));
  for I := 0 to High(Values) do
    Result[I + 1] := AnsiChar(Values[I]);
end;

procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
  Saved: Word;
  Wide: WideString;
  Narrowed: string;
  Encoded, ExpectedUtf8: AnsiString;
begin
  Saved := DefaultSystemCodePage;
  try
    SetMultiByteConversionCodePage(1252);
    Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
    Narrowed := Wide;                    // ה-narrowing שנבדק
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 כ-UTF-8, נבנה בזמן ריצה כדי שאף literal לא יעבור re-encode
  ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
  AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
    SameOctets(Encoded, ExpectedUtf8));
end;

ה-harness עצמו הוא תוכנית LCL, לכן DefaultSystemCodePage הוא CP_UTF8 והבאג בלתי נראה עד שהבדיקה משנה אותו. SetMultiByteConversionCodePage(1252) בתוך try..finally משחזרת את סביבת תוכנית ה-console הרגילה למשך בדיקה אחת. הבדיקה מקצה לקצה הולכת רחוק יותר וטוענת את שני הכיוונים: חבילת XMP שנוצרה בהזרקת markers חייבת להכיל $43 $61 $66 $C3 $A9 ואסור לה להכיל $43 $61 $66 $E9, כך שרגרסיה עתידית שחוזרת לפלט raw של בית יחיד נכשלת בקול במקום לייצר קובץ שנראה סביר ב-hex dump. אם אתם עובדים עם מטא-נתונים לא לטיניים, אותה משמעת הרחבה מנהלת את המקרים בטקסט emoji ו-CJK ששובר טיפול WideChar ב-Delphi

היכן עוד ה-narrowing נוחת

XMP היא הנפגעת הנראית, אך כל bridge מ-TBytes ל-string באותו בסיס קוד נחשף לאותה סכנה. שניים נוספים תוקנו ב-v3.103.1: Utf8BytesToString ו-StringToUtf8Bytes ב-FPdfProduction, שמבצעים round-trip ל-packet של datasets של XFA דרך מחרוזת כדי ש-MergePdfXfaDatasets תוכל להחליף ערכים קשורים, ו-BytesToUtf8 ב-FPdfTrustedList, שמפענחת XML של trusted-list אירופי אחרי הסרת byte-order mark. שני המקומות מכינים כעת את ה-buffer ב-RawByteString, מתייגים אותו CP_UTF8 בלי להמיר ומפענחים עם UTF8ToString. מודול אחד כבר היה חסין, וכדאי להעתיק את הסיבה. כותב XFDF מגדיר טיפוס טקסט משלו בשם XFDFString, שנפתר ל-WideString תחת FPC ול-UnicodeString תחת Delphi, לכן ה-encoder שלו אינו רואה בכלל AnsiString עם codepage. זה התיקון המבני: להשאיר טקסט בטיפוס UTF-16 עד נקודת הסריאליזציה המדויקת, ולתת לפונקציה צרה אחת להיות בעלת ההמרה לבתים. כל באג במשפחה הזו הגיע משדה string שישב באמצע צינור שהיה UTF-16 בקצה אחד ואוקטטים בקצה השני

מה לבדוק בקוד PDF דו-מהדרי משלכם

אם אתם מפיצים Object Pascal שרץ בשני המהדרים וכותב מטא-נתונים ל-PDF תואם תקן, ארבע בדיקות תופסות את רוב המעמד הזה לפני ש-validator עושה זאת

  • חפשו UTF8Encode עם ארגומנט string. ב-FPC הקריאה הזו היא no-op, והיא השורה בעלת התשואה הגבוהה ביותר לביקורת
  • התייחסו לכל משתנה UTF8String כחשוד במצב Delphi. שם הוא AnsiString רגיל, והשמת בתים מקודדים אליו מבצעת להם transcode בחזרה
  • הריצו לפחות רגרסיה אחת תחת SetMultiByteConversionCodePage עם codepage בן בית אחד. Harness של LCL רץ ב-CP_UTF8 ולעולם לא ישחזר תוכנית console רגילה
  • בנו וקטורי בתים צפויים בזמן ריצה והשוו אותם אוקטט-אוקטט. גם literals של מקור וגם = עוברים reconciliation של codepage ויסתירו את הפגם שאתם מחפשים

אין כאן trivia אקזוטית של Free Pascal. זהו המחיר הרגיל של שפה שהשאירה טיפוס מחרוזת מכוון-בתים לצד טיפוס UTF-16, ושני המהדרים בחרו באופן סביר אך שונה מה string אמור להיות. התוצאה המעשית עבור PDF צרה וחדה: מטא-נתונים שנקראים היטב ב-IDE שלכם יכולים להגיע לחבילת ה-XMP כ-UTF-8 לא תקף, ול-ISO 19005-1 6.7.2 לא אכפת איזה מהדר הכניס אותם לשם. אם אתם בונים צינור ארכיוני, שכבת הקידוד ראויה לאותה תשומת לב כמו שאר תהליך התאימות ל-PDF/A שסביבה. רכיב PDFium ל-Delphi שולח את ההמרות האלה כחלק מהספרייה, לכן SaveAsPdfA וחמש אחיות הסטנדרטים שלה פולטות מטא-נתוני UTF-8 תואמים ב-Delphi, ב-Lazarus ובבניות Free Pascal רגילות ללא תצורת codepage מצד caller. תיעוד ה-API המלא והגרסה הנוכחית נמצאים בדף המוצר PDFium Delphi Component