مقاله فنی

بستار زیرمجموعه فونت: گلیف‌های شکل‌گرفته گم‌شده در Delphi

گلیف‌های شکل‌گرفته وقتی زیرمجموعه‌ساز فونت فقط گلیف‌های قابل‌دسترسی از نقطه‌کدهای منتشرشده را نگه می‌دارد، به‌صورت جعبه‌های notdef رندر می‌شوند. HotPDF، مؤلفه بومی VCL برای PDF در Delphi و C++Builder، دقیقاً همین نقص را تا نسخه 2.435.0 با خود حمل می‌کرد: خروجی GSUB در OpenType در یک بیت‌مپ استفاده داخلی ثبت می‌شد که زیرمجموعه‌ساز اعلام می‌کرد رعایتش خواهد کرد، اما هرگز واقعاً آن را نمی‌خواند

این شکستی متفاوت از نقصی است که در باگ EndDoc که زیرمجموعه‌سازی فونت را به‌طور بی‌صدا غیرفعال می‌کرد شرح داده شده است. آن باگ درباره این بود که زیرمجموعه‌سازی چه زمانی نسبت به سریال‌سازی اجرا می‌شود، و آن را به‌طور کامل غیرفعال می‌کرد. این یکی درباره این است که زیرمجموعه چه چیزی را در بر دارد وقتی زیرمجموعه‌سازی دقیقاً طبق برنامه اجرا می‌شود. پایپ‌لاین در لحظه درست فعال می‌شود، پیشوند شش‌حرفی زیرمجموعه دقیقاً همان‌طور که ISO 32000-1 §9.6.4 الزام می‌کند روی /BaseFont ظاهر می‌شود، حجم فایل کوچک‌تر می‌شود، هر صفحه لاتین تمیز چاپ می‌شود، و یک صفحه عربی به‌صورت ردیفی از مستطیل‌های خالی درمی‌آید. باگ‌های ترتیبی وقتی نگاه کنید بلند فریاد می‌زنند؛ باگ‌های بستار برای همیشه ساکت می‌مانند، چون زیرمجموعه از نظر ساختاری معتبر است و فقط درباره فهرست عضویت خودش اشتباه می‌کند

چرا گلیف‌های شکل‌گرفته به‌صورت notdef رندر می‌شوند؟

چون مجموعه نقطه‌کدهایی که یک سند منتشر می‌کند همان مجموعه گلیف‌هایی نیست که سند ترسیم می‌کند، و زیرمجموعه‌سازی که این دو را یکی می‌گیرد هر گلیف تولیدشده توسط شکل‌دهی را حذف می‌کند. شکل‌دهی متن یک دنباله منطقی کاراکتر را به دنباله‌ای از گلیف‌های جای‌گذاری‌شده تبدیل می‌کند، و کل هدفش تولید گلیف‌هایی است که هیچ کاراکتر ورودی منفردی به آن‌ها نگاشت نمی‌شود: یک heh میانی عربی، یک لیگاچر fi، یک همبند دواناگری، یک جایگزین بافتاری که با ویژگی rclt انتخاب شده است. هرکدام از این‌ها شناسه گلیفی است که یک جستجوی GSUB ساخته است، نه چیزی که جدول cmap برای هر کاراکتر در رشته به شما می‌دهد. بنابراین زیرمجموعه‌سازی که صرفاً بر پایه cmap هدایت می‌شود در حال پیمایش نمایه اشتباه است. این زیرمجموعه‌ساز با وفاداری هر گلیفی را که متن می‌توانست پیش از شکل‌دهی استفاده کند نگه می‌دارد، و دقیقاً همان گلیف‌هایی را دور می‌ریزد که متن پس از شکل‌دهی استفاده می‌کند. سپس رندرکننده از فونت جاسازی‌شده GID 1847 را می‌خواهد، زیرمجموعه آن مدخل را در loca صفر کرده است، و به‌جای آن شاخص گلیف 0 برمی‌گردد. شاخص گلیف 0 طبق تعریف OpenType همان notdef است، و به همین دلیل نشانه شکست یک جعبه خالی است نه یک حرف اشتباه یا یک خرابی. هیچ‌چیز در PDF بدشکل نیست؛ فونت به‌سادگی گلیفی را که جریان محتوا خواسته است ندارد

نقطه‌کدها گلیف نیستند: سه منبع یک زیرمجموعه

یک بستار زیرمجموعه درست باید سه منبع مستقل را با هم ادغام کند، هرکدام با انباره‌گر خودش. اولی مجموعه برآمده از نقطه‌کد است: HotPDF هنگام انتشار کاراکترهای BMP، FUnicodeUsedCps را انباشته می‌کند و FUnicodeSmpUsed را برای کاراکترهای صفحه مکمل که از راه جفت‌های جانشین رسیده‌اند، سپس هرکدام را از راه FUnicodeCpToGid به یک شناسه گلیف نگاشت می‌کند. دومی مجموعه برآمده از شکل‌دهی است، شناسه‌های گلیفی که یک جایگزینی GSUB تولید کرده، که از راه MarkUnicodeGlyphUsed و EnableShapingFeatureForSubset در FUnicodeExtraUsedGlyphs ثبت می‌شوند. سومی بستار ترکیبی است: گلیفی که numberOfContours آن در glyf برابر با -1 است، از شناسه‌های گلیف مؤلفه مونتاژ می‌شود، و نگه‌داشتن گلیف ترکیبی هنگام حذف مؤلفه‌هایش یک قالب خالی به‌جای notdef ایجاد می‌کند، که احتمالاً بدتر است چون مثل یک باگ فاصله‌گذاری به‌نظر می‌رسد

HotPDF همیشه منبع اول و سوم را درست انجام داده است. BuildAndApplyUnicodeFontSubset، نقطه ورودی زیرمجموعه‌سازی که EndDoc پیش از سریال‌سازی فراخوانی می‌کند، آرایه گلیف‌های استفاده‌شده را با GID 0 مقداردهی می‌کند، نقطه‌کدهای BMP را می‌پیماید، فهرست استفاده SMP را می‌پیماید، و آرایه را به یک سازنده زیرمجموعه می‌سپارد که مؤلفه‌های ترکیبی را به‌صورت داخلی حل می‌کند. منبع دوم نوشته شده بود اما هرگز مصرف نمی‌شد، و چون هر سه منبع روی محتوای متفاوتی خطا می‌کنند، این شکاف می‌تواند سال‌ها در پایگاه کدی که مجموعه رگرسیونش عمدتاً لاتین است پنهان بماند

آرایه‌ای که نوشته شد اما هرگز خوانده نشد

این قرارداد در سه جا مستندسازی شده بود و در هیچ‌کدام رعایت نمی‌شد. اعلان FUnicodeExtraUsedGlyphs بیان می‌کرد که زیرمجموعه‌ساز EndDoc آن را با استفاده برآمده از نقطه‌کد ادغام می‌کند؛ توضیح سربرگ روی ApplyArabicGSUBRefinement وعده می‌داد که هر GID جایگزین منتشرشده از راه MarkUnicodeGlyphUsed عبور می‌کند تا زیرمجموعه‌ساز گلیف را به فونت جاسازی‌شده بکشد؛ همان وعده کلمه‌به‌کلمه روی ApplyArabicGSUBContextualRefinement برای مسیر rclt نیز ظاهر می‌شد. هر دو فراخوان‌کننده نیمه خودشان را انجام دادند. یک جست‌وجو روی هر ارجاع به این فیلد نیمه دیگر را در حدود نود ثانیه روشن کرد: یک اعلان، یک تخصیص SetLength درون RegisterUnicodeTTF، و نوشتن در دو روال علامت‌گذاری. حتی یک خواندن هم نبود. این همان تشخیصی است که ارزش درونی‌کردن دارد، چون فراتر از فونت‌ها هم تعمیم می‌یابد. وقتی فیلدی توسط چند نقطه فراخوانی نوشته می‌شود اما توسط هیچ‌کدام خوانده نمی‌شود، ویژگی‌ای که نمایندگی می‌کند وجود ندارد، هرچقدر هم که کامنت‌گذاری شده باشد. گام ۱ زیرمجموعه‌ساز آن‌قدر کوچک است که در یک صفحه خوانده شود، و این شکاف وقتی بدانید کجا را نگاه کنید آشکار است

// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
  UsedGlyphs[I] := False;
UsedGlyphs[0] := True;                       // .notdef is always present

for Cp := 0 to $FFFF do                      // source 1a: BMP code points
  if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
     and (Cp < Length(FUnicodeCpToGid)) then
  begin
    GID := FUnicodeCpToGid[Cp];
    if (GID > 0) and (GID < FUnicodeNumGlyphs) then
      UsedGlyphs[GID] := True;
  end;

for I := 0 to High(FUnicodeSmpUsed) do       // source 1b: SMP code points
begin
  GID := FUnicodeSmpUsed[I].GID;
  if (GID > 0) and (GID < FUnicodeNumGlyphs) then
    UsedGlyphs[GID] := True;
end;

// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs

رفع تک‌حلقه‌ای، و علامت‌گذاری خودتان روی گلیف‌ها

رفع مشکل یک اجتماع مجموعه‌هاست، و دلیل امنیت آن از جهت عملیات می‌آید: این عملیات فقط بیت‌ها را ست می‌کند، هرگز پاک نمی‌کند، پس هیچ گلیفی که پیش‌تر از زیرمجموعه جان سالم به‌در می‌برد نمی‌تواند اکنون دور ریخته شود

// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
  if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
    UsedGlyphs[I] := True;

سه ویژگی این تغییر را کم‌ریسک می‌کند نه بازنویسی کل موتور فونت. این تغییر یکنواخت (monotone) است، همان‌طور که گفته شد. این تغییر روی فونت‌هایی که هرگز چیزی را شکل نداده‌اند بی‌اثر است، چون FUnicodeExtraUsedGlyphs کاملاً False می‌ماند و خروجی بایتی یک سند فقط-لاتین بدون تغییر باقی می‌ماند. و پیش از گام ۲ اعمال می‌شود، پس هر دو سازنده زیرمجموعه آن را به ارث می‌برند: سازنده پراکنده که شماره‌گذاری اصلی GID را حفظ می‌کند، و سازنده فشرده _BuildCompactSubsetTTF که HotPDF زیر PDF/A انتخاب می‌کند تا گلیف‌های نگه‌داشته را در بازه‌ای متراکم شماره‌گذاری مجدد کند، maxp.numGlyphs را کوچک کند، و نگاشت قدیم‌به‌جدید را به‌صورت جریان /CIDToGIDMap که ISO 32000-1 §9.7.4.2 الزام می‌کند منتشر کند. هر دو به‌صورت داخلی _TTFWalkCompositeClosure را فرا می‌خوانند، پس گلیف شکل‌گرفته‌ای که تصادفاً ترکیبی هم هست حالا مؤلفه‌هایش را هم می‌کشد. بستار ترکیبی هرگز خراب نبود؛ فقط برای این شناسه‌های گلیف هرگز به آن نرسیده بود، چون شناسه‌های گلیف در مجموعه‌ای که می‌پیماید نبودند. اگر موتور GSUB را مستقیماً خودتان هدایت کنید به‌جای اتکا به پاس‌های اصلاح داخلی، بستار مسئولیت خودتان می‌شود، و هر شناسه گلیف جایگزینی که منتشر می‌کنید باید پیش از آنکه EndDoc مجموعه گلیف‌های استفاده‌شده را منجمد کند، علامت‌گذاری شود

var
  Pdf: THotPDF;
  GIDs: array[0..1] of Word;
  LigGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'shaped.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
    Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
                            sfContextualAlternates];

    GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644);   // lam
    GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627);   // alef
    if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
      Pdf.MarkUnicodeGlyphUsed(LigGID);   // omit this and you get .notdef

    Pdf.EnableShapingFeatureForSubset('rclt');
    Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

EnableShapingFeatureForSubset همتای دسته‌ای فراخوانی تک‌GID است، و عمداً محافظه‌کارانه طراحی شده است. این تابع فهرست جست‌وجوهای GSUB را برای جست‌وجوهایی که به یک برچسب ویژگی چهاربایتی، زیر مسیر اسکریپت و زبان انتخاب‌شده کنونی متصل‌اند می‌پیماید، و شناسه‌های گلیف جایگزینی که آن جست‌وجوها می‌توانند تولید کنند را علامت می‌زند. این تابع وقتی فونت هیچ جدول GSUB ندارد یا ویژگی از آن مسیر غایب است، بی‌اثر و بی‌خطر عمل می‌کند، پس فراخوانی بی‌قید و شرط آن امن است. همچنین عمداً بیش‌ازحد فراگیر است: ممکن است گلیف‌هایی را نگه دارد که یک سند مشخص هرگز ترسیم نمی‌کند. برای زیرمجموعه‌سازی، فراگیری بیش‌ازحد هزینه‌اش بایت است و کم‌شمولی هزینه‌اش درستی، که این معامله را آسان می‌کند. ساختار این جست‌وجوها، و جدول‌های پوشش که تعیین می‌کنند کدام گلیف‌ها مشارکت دارند، در راهنمای جایگزین‌های سبکی GSUB در Delphi خالص پوشش داده شده است

چگونه ثابت می‌کنید گلیف واقعاً در زیرمجموعه است؟

با خواندن فونت منتشرشده، نه با نگاه‌کردن چشمی به صفحه در یک نمایشگری که ممکن است پشت پرده فونت سیستم را جایگزین کند. آزمونی که کل این خانواده باگ را می‌گیرد مکانیکی است: جریان /FontFile2 را از PDF خروجی استخراج کنید، loca را تجزیه کنید، و تأیید کنید که شناسه گلیفی که انتظار دارید مدخلی غیرخالی دارد، یعنی افست شروع و پایانش متفاوت است. مدخل خالی یعنی زیرمجموعه‌ساز تصمیم گرفته که گلیف استفاده‌نشده است. سپس دو عادت این کلاس از باگ را بسیار سخت‌تر می‌کند که دوباره منتشر شود. یک صفحه با اسکریپت شکل‌گرفته را در مجموعه دود خودکار نگه دارید نه فقط در مجموعه بازبینی دستی، چون عربی، دواناگری و خمر مسیرهای بستاری را فعال می‌کنند که هیچ پوشش لاتینی به آن نمی‌رسد. و هر جا انباره‌گری وجود دارد، تأیید کنید که چیزی آن را مصرف می‌کند، چون فیلد فقط-نوشتنی ویژگی‌ای است که کامپایل می‌شود، روی مجموعه اشتباه سبز تست می‌دهد، و هیچ کاری انجام نمی‌دهد

جایی که رفع مشکل متوقف می‌شود

بستار زیرمجموعه برای رندرشدن یک گلیف شکل‌گرفته لازم است، و کافی نیست. گلیف همچنین باید از جریان محتوا قابل‌آدرس‌دهی باشد، که مشکل جداگانه‌ای با مرز خودش است. پاس‌های اصلاح داخلی عربی HotPDF یک جایگزینی را فقط زمانی commit می‌کنند که هر شناسه گلیف جایگزین از راه یک نقطه‌کد شکل ارائه یونیکد با یک اسکن معکوس cmap روی حدود ۶۹۰ نقطه‌کد در بازه U+FB50 تا U+FDFF و U+FE70 تا U+FEFF قابل‌دسترسی باشد. وقتی یک جایگزین روی شناسه گلیفی خارج از آن بازه فرود بیاید، پنجره ورودی بدون تغییر عبور می‌کند به‌جای اینکه چیزی منتشر شود که خواننده نتواند آدرس‌دهی کند؛ جایگزین‌های خاص فونت در شناسه‌های گلیف دلخواه نیاز به یک نقطه‌کد استفاده‌خصوصی مصنوعی دارند که در U+E000 تا U+F8FF تخصیص یافته تا آن‌ها را از مسیر انتشار عبور دهد. پس خلاصه صادقانه این است که رفع مشکل 2.435.0 یک مانع سخت را برداشت نه اینکه داستان را تکمیل کند. پیش از آن، یک گلیف می‌توانست درست شکل بگیرد، درست منتشر شود، و همچنان در زمان زیرمجموعه‌سازی ناپدید شود، که یعنی موتور شکل‌دهی نمی‌توانست سرتاسر مورد اعتماد باشد هرچقدر هم جست‌وجوهایش خوب باشند. آنچه باقی می‌ماند آدرس‌دهی‌پذیری است، و آن محدودیت دست‌کم در نقطه انتشار به‌طور مرئی شکست می‌خورد نه بی‌صدا در یک گام ساخت که پس از هرچیزی که شما نگاه می‌کردید اجرا می‌شود. برای سمت انتشار همین پایپ‌لاین، به راهنمای شکل‌دهی متن عربی و راست‌به‌چپ در PDFهای Delphi مراجعه کنید

زیرمجموعه‌سازی فونت، موتور GSUB، و شکل‌دهی اسکریپت پیچیده که در اینجا شرح داده شد در مؤلفه HotPDF استاندارد برای Delphi و C++Builder ارائه می‌شود؛ صفحه محصول مرجع کامل API برای فراخوانی‌های فونت یونیکد و شکل‌دهی نام‌برده‌شده در بالا را در بر دارد