مقاله فنی

حفظ دقت اعشاری PDF هنگام ذخیره در Delphi و CalRGB

PDFlibPas، یعنی losLab PDF Developer Library، متن اعشاری دقیقی را که برای هر عدد حقیقی در سند parse کرده نگه می‌دارد و هر وقت آن مقدار هرگز تغییر نکرده باشد همان متن را مو‌به‌مو بازمی‌نویسد. از v3.539.19 به بعد تنظیم SetPrecision فقط بر عددهایی حاکم است که خود کتابخانه می‌سازد یا ویرایش می‌کند، پس یک بارگذاری-و-ذخیرهٔ معمولی دیگر یک /Gamma در CalRGB با مقدار 2.22221 را به 2.2222 گرد نمی‌کند و رنگ صفحه‌ای را که کسی دستش نزده جابه‌جا نمی‌کند. این تغییر در کد کوچک است و در آنچه دربارهٔ parserها می‌گوید بزرگ: مقداری که decode می‌کنی و literalی که بیرون می‌دهی دو چیز متفاوت‌اند و یک رفت‌وبرگشت Double یک تبدیل همان‌مانندی نیست

چرا یک ذخیره که چیزی را عوض نکرده بود رنگ صفحه را جابه‌جا کرد؟

چون پارامترهای فضای رنگ داشتند دوباره قالب‌بندی می‌شدند، نه خود تصویر. فایلی که این را رو کرد یک سند اداری 35-صفحه‌ای در کورپوس رگرسیون محلی است با یک تصویر هدر که در هر صفحه تکرار می‌شود. بارگذاری و بلافاصله ذخیره کردنش streamهای تصویری تولید کرد که byte-به-byte با ورودی یکسان بودند و مقایسهٔ hash مربوط به stream سند را تغییرنکرده گزارش داد. مقایسهٔ رندرشده اما مخالف بود: هر 35 صفحه در هدر اختلاف پیکسلی نشان می‌دادند و جای دیگری نه

تصویر هدر از یک فضای رنگ CalRGB رسم می‌شود که ISO 32000-1 §8.6.5.3 آن را با یک /WhitePoint، یک آرایهٔ اختیاری سه-عنصری /Gamma و یک /Matrix اختیاری نه-عنصری تعریف می‌کند. این آرایه‌ها در dictionary فضای رنگ شیءهای عددی خالص‌اند. TPDFNumeric هر یک را فقط به‌صورت یک Double ذخیره می‌کرد و بس، و TPDFNumeric.Output آن Double را از PDFPrecNum عبور می‌داد که پیش‌فرضش چهار رقم اعشار است. پس /Gamma از 2.22221 به 2.2222 می‌رفت، یک درایهٔ matrix از 0.71519 به 0.7152، و renderer با کمال وفاداری رنگ‌های کمی متفاوتی از کالیبراسیون کمی متفاوت تولید می‌کرد. byteهای تصویر بی‌گناه بودند؛ عددهای دور و برشان نه. بخش ناراحت‌کننده این است که چقدر نامرئی بود. مقایسهٔ byteهای stream decode‌شده نمی‌تواند ببیندش، چون عددها در یک dictionary زندگی می‌کنند نه در یک stream. مقایسهٔ payloadهای پیوست هم نمی‌تواند ببیندش. حتی diff بازنگری که در مقالهٔ سطح تغییر توصیف شده روی بدنهٔ نرمال‌شدهٔ شیء اثر انگشت می‌گیرد، پس هر دو بازنگری به یک مقدار hash می‌شوند و diff آن دو را یکسان گزارش می‌کند. تنها رندر کردن بود که گرفتارش کرد، و همین است که کورپوس پایه‌ای را وامی‌دارد هر صفحه را رندر کند نه اینکه فقط به بررسی‌های ساختاری اعتماد کند

جایی که PDFlibPas دقت CalRGB را در یک ذخیرهٔ بی‌اثر در Delphi از دست می‌داد: مقدار parse‌شدهٔ /Gamma برابر 2.22221 و یک درایهٔ matrix برابر 0.71519 در TPDFNumeric به‌صورت Double می‌مانند، Output آن‌ها را از طریق PLDoubleToStr با PDFPrecNum در چهار رقم اعشار قالب‌بندی می‌کند، هر بررسی ساختاری سند را تغییرنکرده گزارش می‌دهد و فقط مقایسهٔ رندر نشان می‌دهد هر 35 تصویر هدر جابه‌جا شده‌اند
byteهای تصویر بی‌گناه بودند: TPDFNumeric عددهای کالیبراسیون دور و برشان را از PDFPrecNum عبور می‌داد، پس هم hashهای stream و هم diff اثر انگشت بازنگری‌های یکسان گزارش می‌کردند و در همان حال renderer روی هر صفحه رنگ‌های کمی متفاوتی تولید می‌کرد

مقداری که parse کردی همان literalی نیست که باید بنویسی

یک عدد حقیقی در PDF یک رشتهٔ اعشاری است و ISO 32000-1 §7.3.3 صریح می‌گوید فقط یک رشتهٔ اعشاری است: نه نماد radix و نه فرم exponent. بعد Annex C دقتی را فهرست می‌کند که از یک پیاده‌سازی انتظار می‌رود رعایت کند، تقریباً پنج رقم معنادار اعشاری در بخش کسری. پیش‌فرض دقت خروجی برابر چهار از قبل زیر آن است و نزدیک صفر بدتر هم می‌شود: PLDoubleToStr مقدار را مقیاس می‌دهد، به عدد صحیح گرد می‌کند و اگر حاصل صفر باشد 0 بیرون می‌دهد؛ پس یک درایهٔ matrix با مقدار -0.000012345 یک رقم از دست نمی‌دهد، کاملاً ناپدید می‌شود

بالا بردن پیش‌فرض فقط پرتگاه را جابه‌جا می‌کند. fix این است که دست از این تظاهر برداریم که Double همان عدد است. وقتی tokenizer در TPDFStructure.Decode یک real استاندارد را تشخیص می‌دهد — یعنی توکنی که یک نقطهٔ اعشار دارد و هیچ نشانگر exponent ندارد — متن مبدأ را در فیلد جدید FOriginalText در کنار مقدار تبدیل‌شده ذخیره می‌کند. بعد Output آن متن را ترجیح می‌دهد و فقط وقتی چیزی برای ترجیح دادن نباشد به قالب‌بندی برمی‌گردد

نحوهٔ حفظ متن اعشاری parse‌شده در PDFlibPas در Delphi: tokenizer در TPDFStructure.Decode متن مبدأ را برای هر توکنی که نقطهٔ اعشار دارد و exponent ندارد در FOriginalText نگه می‌دارد، Output همان متن را مو‌به‌مو می‌نویسد به‌جای صدا زدن PLDoubleToStr، و SetTo آن را پاک می‌کند چون عدد ویرایش‌شده یک عدد جدید است
مقداری که decode می‌کنی و literalی که بیرون می‌دهی دو چیز متفاوت‌اند: ترجیح دادن متن parse‌شده مقدار 2.22221 را دقیق نگه می‌دارد، در حالی که عددهای ساخته یا ویرایش‌شدهٔ کتابخانه باز هم از PDFPrecNum پیروی می‌کنند و این تنظیم هرگز به ورودی دست‌نخورده نمی‌رسد
// Lib/PDFlibStruct.pas — کل fix در سمت خروجی
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // عدد ویرایش‌شده یک عدد جدید است
  FValue:= Value;
  FChanged:= True;
End;

دو مرز عمدی‌اند. عددهای صحیح حفظ نمی‌شوند، چون قالب‌بندی عدد صحیح از قبل بی‌اتلاف است. فرم‌های exponent مثل 6.02E23 به خاطر تولیدکننده‌های خراب در ورودی تحمل می‌شوند اما در خروجی حفظ نمی‌شوند، چون نوشتن دوباره‌شان یک نحو را که §7.3.3 ممنوع کرده ماندگار می‌کند؛ آن‌ها مثل هر عدد تولیدشدهٔ دیگری از formatter می‌گذرند. tokenizer هم پیش از ذخیرهٔ متن، همان ترمیم کمینهٔ همیشگی‌اش را اعمال می‌کند، پس یک literal با نقطهٔ ابتدایی مثل .5 به‌صورت 0.5 نگه داشته می‌شود و یک literal با نقطهٔ انتهایی مثل 5. به‌صورت 5.0. هر دو برای هر خواننده‌ای همان عددند و خیلی فراگیرتر پذیرفته می‌شوند

SetPrecision پس از v3.539.19 چه چیزی را تضمین می‌کند؟

TPDFlib.SetPrecision حالا تعداد ارقام اعشار عددهایی را کنترل می‌کند که خودِ کتابخانه تولید می‌کند: مقدارهایی که از طریق painter رسم می‌شوند، عددهایی که از یک Double ساخته می‌شوند مثل از طریق NewNumeric، و هر مقدار parse‌شده‌ای که بعداً با SetTo ویرایش شده باشد. حواست باشد که متنی که از طریق object API decode می‌شود، مثلاً یک literal که به SetObjectFromString داده شده، از همان tokenizer می‌گذرد و همان‌طور حفظ می‌شود. یک اعشار parse‌شده که هرگز ویرایش نشده، بی‌توجه به این تنظیم دقت ورودی‌اش را نگه می‌دارد و عوض کردن تنظیم بعد از بارگذاری به‌صورت عقب‌گرد به آن دست نمی‌زند. مدخل مرجع SetPrecision هم در همان انتشار به‌روزرسانی شد تا دقیقاً همین را بگوید، چون متن قبلی طوری بود که نشان می‌داد این تنظیم به هر عددی در فایل اعمال می‌شود

پاک شدن در SetTo رخ می‌دهد، نه با استخراج از پرچم Changed، و همین تفاوت مهم است. خط لولهٔ ذخیره پرچم Changed را روی objectها به‌محض نوشته شدن بازنشانی می‌کند، پس بررسی‌ای به شکل «متن اصلی را بیرون بده مگر اینکه تغییر کرده باشد» شروع می‌کرد به بیرون دادن متن کهنه برای مقداری که در همان سشن ویرایش، ذخیره و دوباره ویرایش شده بود. گره زدن متن اصلی به خود انتساب باعث می‌شود این دو نتوانند با هم اختلاف پیدا کنند. تست رگرسیون هر یک از این رفتارها را با مقدارهای همان فایل اصلی پین می‌کند

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // ورودی ویرایش‌نشده مو‌به‌مو زنده می‌ماند، از جمله مقداری که
    // قالب‌بندی چهاررقمی آن را به 0 فرو می‌ریخت
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // یک ویرایش متن اصلی را دور می‌ریزد و از PDFPrecNum پیروی می‌کند
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // پایین آوردن دقت بعد از این به ورودی ویرایش‌نشده نمی‌رسد
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

چرا مدل محتوا باز هم عددها را نرمال می‌کند؟

چون TPDFContentProgram به operandهای عددی canonical قول داده و آن قول از متن مو‌به‌مو داخل یک stream محتوا می‌ارزد. مدل محتوای قابل‌ویرایش، همانی که ردیاب حالت گرافیکی رویش ساخته شده، وجود دارد تا NormalizeContentStreams و بهینه‌ساز و Emit از ورودی دلخواه خروجی پایدار و قابل‌مقایسه بسازند. اگر یک operand parse‌شده متن اصلی‌اش را به داخل مدل می‌برد، یک توالی عملگر مثل 0.50000 0 0 RG متفاوت از 0.5 0 0 RG بیرون می‌آمد و هر مقایسهٔ پایین‌دستی با عادت‌های قالب‌بندی تولیدکننده رانش پیدا می‌کرد

پس مدل متن اصلی را در دو نقطهٔ ورودی‌اش حذف می‌کند. NormalizeContentNumbers روی هر operand اجرا می‌شود وقتی parser آن را push می‌کند و دوباره داخل SetOperand وقتی منبع تأمین‌شدهٔ caller decode می‌شود، و در آرایه‌ها و dictionaryها بازگشتی پیش می‌رود تا dash patternها و آرایه‌های TJ و dictionaryهای property مربوط به محتوای علامت‌خورده را هم پوشش بدهد. صدا زدن SetTo(AsDouble) روی هر عدد کافی است، چون دقیقاً همان عملیاتی است که متن را پاک می‌کند. دادهٔ خام تصویر inline دست‌نخورده رها می‌شود، همان‌طور که همیشه بود

چرا مدل محتوای PDFlibPas باز هم عددها را نرمال می‌کند: NormalizeContentNumbers جایی که parser هر operand را push می‌کند و دوباره داخل SetOperand اجرا می‌شود، در آرایه‌ها و dictionaryها بازگشتی پیش می‌رود تا dash patternها و آرایه‌های TJ و dictionaryهای property محتوای علامت‌خورده پوشش داده شوند، و SetTo AsDouble متن اصلی را پاک می‌کند تا 0.50000 و 0.5 یکسان بیرون بیایند
operandهای عددی canonical همان قول مدل محتواست: دادهٔ خام تصویر inline دست‌نخورده رها می‌شود و عددهای dictionary دست‌نخورده بیرون از stream محتوا تضمین مو‌به‌مو را نگه می‌دارند، پس یک جفت سادهٔ LoadFromFile و SaveToFile باز هم آن‌ها را حفظ می‌کند
// Lib/PDFlibContentModel.pas — مدل محتوا قراردادش را نگه می‌دارد
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

پس قاعدهٔ عملی برای callerها ساده است. یک LoadFromFile ساده و بعد SaveToFile streamهای محتوای دست‌نخورده و عددهای dictionary دست‌نخورده را همان‌طور که بودند رها می‌کند. صفحه‌ای که از NormalizeContentStreams بگذرد، یا هر ویرایشی که از طریق مدل محتوا انجام شود، به‌طور طراحی‌شده canonical بیرون می‌آید و بقیهٔ سند هنوز حفظ می‌شود. این‌ها دو درخواست متفاوت‌اند و حالا دو کار متفاوت می‌کنند

چه چیزی هزینه دارد و تضمین کجا تمام می‌شود

هر TPDFNumeric حالا یک ارجاع AnsiString اضافه حمل می‌کند و هر اعشار parse‌شده متن مبدأش را تا پایان عمر همان شیء زنده نگه می‌دارد. روی سندی با میلیون‌ها عدد حقیقی این حافظهٔ واقعی است و جای‌اش در هر اندازه‌گیری اسناد بزرگ است، نه اینکه با دست رد شود. تضمین همچنین به سند خودِ آن عدد محدود است: کپی کردن objectها میان سندها یا بازسازی مقدارها از طریق object API عددهای جدید تولید می‌کند که مثل هر عدد جدید دیگری از دقت خروجی پیروی می‌کنند. ارزش دارد دقیق باشیم دربارهٔ آنچه این انتشار ادعا می‌کند و نمی‌کند. یک بارگذاری-و-ذخیرهٔ سند دست‌نخورده حالا همان عددهای کالیبراسیونی را حفظ می‌کند که renderer واقعاً مصرف می‌کند، که همان خصیصه‌ای است که کورپوس پایه‌ای بررسی می‌کند. ادعا نمی‌کند که خروجی byte-به-byte یکسان است، چون آن هم به شماره‌گذاری objectها و فشرده‌سازی stream و شناسهٔ trailer بستگی دارد که در مقالهٔ شناسهٔ قطعی PDF بحث شده. و کاری نمی‌کند که diff اثر انگشت اختلاف‌های گرد کردن را در فایل‌های تولیدشدهٔ نرم‌افزارهای دیگر ببیند، چون آن‌ها هنوز بدنهٔ نرمال‌شده را hash می‌کنند. این درس خیلی فراتر از CalRGB تعمیم می‌یابد: وقتی یک parser فقط مقدار تبدیل‌شده را نگه می‌دارد، هر ذخیره‌ای یک ویرایش است و تنها راه متوجه شدن، نگاه کردن به نتیجهٔ رندر است. برخورد با عددها و معناشناسی SetPrecision روی صفحهٔ محصول losLab PDF Developer Library مستند شده است