مقاله فنی

اعداد PDF مستقل از locale برای اپ‌های Delphi با اعشار کاما

PDFlibPas، یعنی losLab PDF Developer Library برای Delphi، هر عددی را که در یک content stream می‌نویسد با جداکنندهٔ اعشار نقطه و بدون توان فرمت می‌کند، فرقی نمی‌کند تنظیمات منطقه‌ای Windows چه بگوید. از v3.539.26 خروجی AddPageMatrix و ScalePage و DeskewPage و RedactRegion و text-to-path و recoloring عملوندها را از طریق PLDoubleToStrConst فرمت می‌کند، و از v3.539.33 parserهایی که آن اعداد را دوباره می‌خوانند به‌جای locale سیستم از PLTryStrToFloatInvariant استفاده می‌کنند. روی یک ماشین آلمانی یا فرانسوی یا برزیلی، همان کد حالا همان بایت‌هایی را تولید می‌کند که روی یک ماشین آمریکا، و این تنها رفتاری است که یک فرمت فایل می‌تواند تحمل کند

چرا یک locale با اعشار کاما یک PDF را بدون خطا خراب می‌کند؟

یک locale با اعشار کاما PDF را بی‌سروصدا خراب می‌کند چون کاما در سینتکس PDF نویسهٔ عددی نیست، پس آسیب به‌شکل توکن‌های معتبر با معنی اشتباه خوانده می‌شود. قبل از fix، PLFloatToStr چیزی نبود جز یک فراخوانی خام FloatToStr، و FloatToStr از FormatSettings.DecimalSeparator پیروی می‌کند. با جداکنندهٔ کاما، AddPageMatrix(0.5, 0.5, 0, 0) می‌نوشت 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 در یک عدد فقط ارقام، یک نقطه و علامت پیشوندی را اجازه می‌دهد و هیچ چیز دیگر را، پس parser محتوا آن خط را به‌شکل عدد 0 به‌همراه یک توکن ناشناختهٔ ,5 می‌خواند و عملگر cm با عملوندهای اشتباه تمام می‌شد. هیچ چیزی raise نمی‌شد، هیچ چیزی لاگ نمی‌شد. صفحه به‌سادگی با یک ماتریس تبدیل که منحرف شده بود رندر می‌شد، و برگشتن از یک رسم جابه‌جاشده به یک تنظیم locale یک بعدازظهر رقت‌انگیز است

نقص دوم پشت اولی پنهان شده بود. FloatToStr از فرمت ffGeneral استفاده می‌کند که وقتی اندازه از 1E-4 پایین‌تر برود به نشان‌گذاری توان سوئیچ می‌کند، پس یک offset خیلی کوچک به‌شکل 1E-5 درمی‌آمد. همان §7.3.3 تصریح می‌کند که PDF شکل توانی را پشتیبانی نمی‌کند، یعنی حتی یک ماشین با locale آمریکا هم با مقداری به‌قدر کافی کوچک می‌توانست یک عملوند نامعتبر بنویسد. تست‌های رگرسیون این انتشار هر دو شکل خرابی را ثابت نگه می‌دارند: جداکننده را به کاما می‌چرخانند، API را صدا می‌زنند و محتوای حاصل را برای هر توکنی که کاما یا توان دارد می‌کاوند

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // شبیه‌سازی یک دسکتاپ de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 و بعدتر می‌نویسند: 0.5 0 0 0.25 0.00001 12.75 cm
      // بیلدهای قدیمی‌تر می‌نوشتند:        0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
AddPageMatrix در PDFlibPas روی یک دسکتاپ با اعشار کاما قبل از fix می‌نوشت 0,5 0 0 0,25 1E-5 12,75 cm، که parser مربوط به PDF آن را به‌شکل عدد 0 به‌همراه توکن‌های ناشناخته می‌خواند، cm را با عملوندهای اشتباه جا می‌گذاشت و صفحه را بی‌سروصدا transform می‌کرد، در حالی که PLDoubleToStrConst اعشارهای نقطه‌ای معتبر می‌نویسد
کاما در سینتکس PDF نویسهٔ عددی نیست، پس آسیب به‌شکل توکن‌های معتبر با معنی اشتباه خوانده می‌شود؛ و توان‌های ffGeneral مثل 1E-5 روی هر locale ای نامعتبر بودند، نه فقط روی کامایی‌ها

دو نوع عدد، دو خانوادهٔ هلپر

fix در PDFlibPas یک تفکیک سخت‌گیرانه است: اعدادی که به آدم‌ها نشان داده می‌شوند می‌توانند از locale پیروی کنند، و اعدادی که برای ماشین نوشته می‌شوند هرگز نمی‌کنند. PLFloatToStr و PLStrToFloat برای متن رو-به-کاربر در PDFlibExtra.pas می‌مانند و اعلانشان حالا کامنتی حمل می‌کند که دقیقاً همین را می‌گوید. هر چیزی که به سینتکس PDF ختم می‌شود از PLDoubleToStrConst با تعداد ثابتی رقم اعشار که برای آن کار انتخاب شده می‌گذرد: شش تا برای ماتریس‌ها، چهار تا برای مختصات و تنظیم‌های TJ، سه تا برای رنگ‌ها و مستطیل‌های FDF. audit مربوط به v3.539.26 به call siteهای بیشتری از آنچه گزارش باگ اصلی می‌گفت دست زد:

  • AddPageMatrix و ScalePage و DeskewPage که همه یک cm به محتوای موجود صفحه اضافه می‌کنند
  • سازنده‌های عنصر صفحه که ریست‌های Tm و جلو رفتن‌های TJ و transformهای cm تولید می‌کنند
  • ماتریس‌های جای‌گذاری گلایف و نقاط outline در مبدل text-to-path
  • جعبهٔ پر کردن سیاهی که RedactRegion اضافه می‌کند، مقادیر /Rect در export مربوط به FDF و عملوندهایی که recoloring می‌نویسد

PLDoubleToStrConst یک فرمت‌کنندهٔ دست‌ساز است نه یک wrapper دور FloatToStrF، و سه خاصیتش اینجا اهمیت دارد. همیشه نقطه می‌نویسد و صفرهای انتهایی را می‌تراشد، پس 0.5 همان 0.5 می‌ماند نه 0.500000. برای ورودی متناهی هرگز توان نمی‌نویسد. و مقدار غیرصفر کوچک‌تر از دقت درخواستی ارقام معنادارش را نگه می‌دارد به‌جای آنکه به صفر فروریزد، پس PLDoubleToStrConst(1E-9, 6) برمی‌گرداند 0.000000001؛ فقط مقادیر پایین‌تر از حدود 5E-16 به 0 تبدیل می‌شوند. آن قاعدهٔ آخر از این جهت وجود دارد که گرد کردن یک ضریب مقیاس خیلی کوچک به صفر یک ماتریس معتبر را singular می‌کند، که باگ بدتری است از همان که در حال تعمیرش بودیم

PDFlibPas فرمت کردن اعداد را به دو نیم تقسیم می‌کند: PLFloatToStr و PLStrToFloat برای متن رو-به-کاربر به locale وابسته می‌مانند، در حالی که PLDoubleToStrConst و PLTryStrToFloatInvariant هر چیزی را که به سینتکس PDF تبدیل می‌شود با نقطه، بدون توان و با دقت ثابت شش یا چهار یا سه رقمی برای هر کار فرمت می‌کنند
فرمت‌کنندهٔ invariant عمداً دست‌ساز است: صفرهای انتهایی را می‌تراشد، هرگز توان نمی‌نویسد و ارقام معنادار مقادیر خیلی کوچک را نگه می‌دارد، چون گرد کردن یک ضریب مقیاس به صفر یک ماتریس معتبر را singular می‌کرد
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // خروجی ماشینی: اعشار نقطه‌ای، بدون توان، صفرهای انتهایی حذف شده
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // 4 رقم معنادار را نگه می‌دارد
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // ورودی ماشینی: شکست نرم به‌جای EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // اعداد content هرگز از کاما استفاده نمی‌کنند
end;

چرا سمت تجزیه خطرناک‌تر از سمت نوشتن است؟

سمت تجزیه خطرناک‌تر است چون یک parser وابسته به locale عدد غلط تولید نمی‌کند، بلکه exception بالا می‌دهد. PLStrToFloat صدایش می‌زند StrToFloat را که وقتی متن با جداکنندهٔ سیستم جور نباشد EConvertError بالا می‌دهد. روی سیستم با اعشار کاما این یعنی RecolorPage همان لحظه‌ای که با یک عملگر معمولی 0.5 g روبه‌رو می‌شد متوقف می‌شد، پس هر صفحهٔ دنیای واقعی شکست می‌خورد، نه فقط صفحات عجیب. RenderPageRegionToFile فرمت clip مستندشدهٔ خودش یعنی "10.5,20.5,50.5,40.5" را رد می‌کرد، و attributeهای طول SVG و رنگ‌های export مربوط به SVG و فهرست‌های رأس حاشیه‌نویسی و مقادیر solidity مربوط به output intent یا رد می‌شدند یا بی‌سروصدا با پیش‌فرض‌ها جایگزین می‌شدند. کتابخانه‌ای که روی ماشین توسعه‌دهنده بی‌نقص کار می‌کند و روی اولین مشتری در مونیخ شکست می‌خورد، دقیقاً همان جنس کدی است که مثل موارد مقاله دربارهٔ کد Delphi که اتفاقی کار می‌کند فقط به خاطر جایی که تست شده درست به نظر می‌رسد

v3.539.33 هر فراخوانی StrToFloat و TryStrToFloat را بر اساس اینکه ورودی‌اش از کجا می‌آید دسته‌بندی کرد. عملوندهای content stream و attributeهای SVG و رشته‌های رنگ نقاش‌ها و فهرست‌های جداشده با کاما برای clip و رأس‌ها همگی سینتکس نقطه‌ای ثابتی دارند، پس حالا از PLTryStrToFloatInvariant می‌گذرند که متن را می‌تراشد، با PLInvariantFormatSettings تجزیه‌اش می‌کند و برای ورودی خالی یا بدشکل یا نامتناهی به‌جای بالا دادن exception برمی‌گرداند False. یک فهرست جداشده با کاما جایی برای مصالحه نمی‌گذارد، چون کاما نمی‌تواند هم delimiter فهرست باشد هم علامت اعشار. همان پاس همچنین یک نوشتن خارج از بازه را هم تعمیر کرد: RenderPageRegionToFile قبلاً یک مقدار clip پنجم را پشت buffer چهار عنصری‌اش ذخیره می‌کرد. برای پایپ‌لاین recoloring که در راهنمای تبدیل یک PDF به یک color space توصیف شده، نتیجهٔ عملی این است که RecolorPage و RecolorDocument دیگر روی سیستم با اعشار کاما متوقف نمی‌شوند. مقادیر قاعده‌ای که فراخوان در CheckDocumentPolicy تایپ می‌کند همان یک مورد تجزیه است که به‌جایش از هلپر مداراگر استفاده می‌کند، به دلیلی که بخش بعد توضیح می‌دهد

اگر فقط یک سرِ رفت‌وبرگشت را تعمیر کنی چه می‌شود؟

تعمیر فقط یک سرِ رفت‌وبرگشت locale کدی را می‌شکند که قبلاً کار می‌کرد، برای همین تغییر attribute ساختاری در v3.539.32 نویسنده و خواننده را با هم جابه‌جا کرد. wrapperهای SetStructElem* اعداد را به‌شکل رشته رله می‌کنند: SetStructElemBBox چهار مقدار را در یک رشته فرمت می‌کند، از طریق AddTagAttribute ذخیره‌اش می‌کند، و نویسندهٔ /A بعداً آن رشته را تجزیه می‌کند تا تصمیم بگیرد به عدد تبدیل می‌شود یا آرایه یا name. هر دو سر از locale سیستم استفاده می‌کردند، پس روی سیستم با اعشار کاما رفت‌وبرگشت با خودش سازگار بود. باگ فقط وقتی خودش را نشان داد که فراخوانی مستندات را دنبال کرد و به AddTagAttribute مقدار "0.5" داد: خواننده نمی‌توانست آن را تجزیه کند و name مربوط به PDF یعنی /0.5 تولید می‌کرد. placeholder مربوط به PDF/VCR مشکل آینه‌ای داشت، چون کتابخانه GTS_BBox را با نقطه تولید می‌کرد و بعد همان را قبل از ذخیره با locale اعتبارسنجی می‌کرد

تغییر دادن فقط نویسنده به نقطه بدتر از هیچ کاری بود، چون بعد هر مقدار SetStructElem* خوانندهٔ وابسته به locale را شکست می‌داد و به یک name تنزل پیدا می‌کرد. پس نویسنده‌ها حالا از PLDoubleToStrConst(v, 6) استفاده می‌کنند و خواننده از PLTryStrToFloatLenient جدید، که اول شکل نقطه‌ای را امتحان می‌کند و به locale سیستم برمی‌گردد. فراخوانی با locale کاما که در گذشته "1,25" می‌داد هنوز عدد 1.25 را می‌گیرد. موازنه عمدی و مستند است: روی سیستم آلمانی "1.500" قبلاً به یک name تبدیل می‌شد چون StrToFloat جداکننده‌های هزارگان را رد می‌کند و حالا به‌شکل 1.5 خوانده می‌شود، در حالی که رشته‌های literal یعنی NAN و INF دیگر به‌عنوان عدد قبول نمی‌شوند

SetStructElemBBox در PDFlibPas و هم‌خانواده‌هایش اعداد را به‌شکل رشته از طریق AddTagAttribute رله می‌کنند و نویسندهٔ /A آن رشته‌ها را دوباره تجزیه می‌کند، پس v3.539.32 هر دو سر را با هم جابه‌جا کرد: PLDoubleToStrConst با نقطه می‌نویسد و PLTryStrToFloatLenient اول نقطه را می‌خواند با برگشت به locale، پس یک 1,25 با locale کاما هنوز به‌شکل 1.25 خوانده می‌شود
تعمیر فقط نویسنده هر attribute عنصر ساختاری را به یک name مربوط به PDF تنزل می‌داد؛ برای همین رفت‌وبرگشت یا هر دو سر را با هم جابه‌جا می‌کند یا هیچ‌کدام را
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // فراخوانی با اعشار کاما
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5، پیش از این /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // همچنان /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

NaN و بی‌نهایت کجا متوقف می‌شوند

AddPageMatrix و ScalePage و RedactRegion حالا آرگومان‌های NaN و بی‌نهایت را از همان ابتدا رد می‌کنند و 0 برمی‌گردانند، چون هیچ عدد PDF ای نمی‌تواند آن‌ها را نمایش بدهد. ScalePage از قبل ضریب‌های صفر و کمتر را رد می‌کرد، اما NaN از یک تست <= 0 عبور می‌کند، پس یک ضریب NaN تا خود فرمت‌کننده پیش می‌رفت. در v3.539.26 آن فرمت‌کننده هنوز روی NaN صدایش می‌زد Round را که روی Win32 جایی که یونیت x87 عملیات نامعتبر را ماسک نمی‌کند EInvalidOp بالا می‌دهد؛ v3.539.31 کاری کرد که PLDoubleToStrConst برای NaN بنویسد 0 به‌عنوان آخرین خط دفاع، اما صفر داخل یک ماتریس یعنی transformی singular، پس چک در سطح API هنوز تعمیر واقعی است. دو مرز عمداً سر جایشان مانده‌اند. رشته‌های وضعیت metafile داخل یک پروسه با locale نوشته و خوانده می‌شوند و هرگز آن را ترک نمی‌کنند، پس دست‌نخورده رها شدند. و تستی که 1E-5 را از مسیر عنصر صفحه فرمت می‌کند باید محتوا را قبل از بازنویسی لایه بخواند، چون تولید دوبارهٔ عملوندها با دقت سند به‌طور مشروع آن مقدار را به 0 تبدیل می‌کند

اگر اپلیکیشنت به مشتری‌های بیرون از دنیای اعشار-نقطه سفر می‌کند، امن‌ترین عادت همان است که سویت تست PDFlibPas حالا استفاده می‌کند: مسیرهای تولید PDF را یک بار با FormatSettings.DecimalSeparator روی کاما اجرا کن و خروجی را برای کاما و توان‌ها بکاو. مقاله دربارهٔ حفظ دقت اعشاری تجزیه‌شده نیمهٔ دیگر همین داستان را پوشش می‌دهد، اینکه چطور اعداد خوانده‌شده از یک فایل موجود متن دقیقشان را روی ذخیره نگه می‌دارند. دانلودها و مرجع کامل API و بیلد آزمایشی در صفحهٔ محصول PDFlibPas Delphi PDF library هستند