רכיב 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