مقاله فنی

fix ToUnicode: استخراج NBSP و خط تیرهٔ نرم در Delphi

PDFium Component برای Delphi فونت‌های سیستمی که TPdf.AddText استفاده می‌کند را به‌عنوان CID font کلیدخورده با code point یونیکد جاسازی می‌کند، پس هر CID دقیقاً یک نگاشت ToUnicode حمل می‌کند. همین چیزی است که جلوی این را می‌گیرد که فاصله‌های استخراج‌شده به‌صورت U+00A0 (فاصلهٔ بدون شکستن) و خط تیره‌ها به‌صورت U+00AD (خط تیرهٔ نرم) برگردند، هم از سند زنده و هم از فایل ذخیره‌شده

علامت کثیف است چون نامرئی است. یک ایندکس جستجو «two-x» را نمی‌گیرد چون رشتهٔ ذخیره‌شده خط تیرهٔ نرم دارد، یک export به CSV جور دیگری می‌شکند، یک ابزار diff خط‌هایی را علامت می‌زند که در هر viewerی یکسان به نظر می‌رسند. هیچ‌چیز در صفحهٔ رندرشده غلط نیست؛ فقط یونیکدی که پشت گلیف‌هاست غلط است

چرا فاصله‌های استخراج‌شده به U+00A0 برمی‌گردند؟

فاصله‌های استخراج‌شده به U+00A0 تبدیل می‌شوند چون CMap مربوط به ToUnicode که PDFium در FPDFText_LoadFont تولید می‌کند با گلیف کلید خورده، و یک گلیف را می‌شود از دو code point رسید. در Arial، گلیف 3 هم به U+0020 و هم به U+00A0 سرویس می‌دهد، و گلیف خط تیره هم به U+002D و هم به U+00AD. CMap تولیدشده بنابراین همان CID را دو بار نگاشت می‌کند، یک‌بار از طریق مدخل bfchar و یک‌بار از طریق bfrange به فرم آرایه، و هر مدخلی که قاعدهٔ اولویتِ reader ترجیحش بدهد متن استخراج‌شده می‌شود

چرا یک گلیف Arial استخراج متن PDF را در Delphi شکست: U+0020 و U+00A0 به گلیف 3 می‌رسند و U+002D و U+00AD به گلیف خط تیره، پس CMap مربوط به ToUnicode که تولید می‌شود CID 0003 را دو بار با یک مدخل bfchar و یک bfrange آرایه‌ای نگاشت می‌کند، و قاعدهٔ اولویت reader تصمیم می‌گیرد کدام code point استخراج شود
اولویت کمترین-می‌برد سال‌ها فاصله‌ها را ساده نگه داشت، تا اینکه یک تغییر بالادستی به بیشترین-می‌برد باعث شد هر فاصلهٔ AddText به‌عنوان NBSP و هر خط تیره به‌عنوان خط تیرهٔ نرم استخراج شود
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

مدت‌ها این تناقض بی‌آزار بود، چون reader مربوط به PDFium می‌گذاشت کمترین نگاشت ببرد. یک تغییر بالادستی reader را به بیشترین-می‌برد سوییچ کرد، و از آن بیلد به بعد هر فاصله‌ای که با AddText نوشته می‌شد به‌عنوان NBSP و هر خط تیره به‌عنوان خط تیرهٔ نرم استخراج می‌شد. الگوی جفت‌ها را ببینید: 0x20/0xA0 و 0x2D/0xAD فقط در بیت بالا فرق دارند، و دقیقاً همان چیزی است که از فونتی انتظار می‌رود که cmap آن شبیه‌های Latin-1 را به یک outline می‌فرستد. اگر کد استخراج شما دیروز خوب بود و حالا روی کاراکترهای نامرئی می‌شکند، code pointها را dump کنید به‌جای اعتماد به نمای debugger؛ اصول بیرون کشیدن متن در استخراج متن از سندهای PDF با PDFium در Delphi پوشش داده شده

uses
  SysUtils, PDFium;

const
  // فاصله/U+00A0 و خط تیره/U+00AD یک گلیف Arial را شریک‌اند، همان‌طور که
  // امگای یونانی (U+03A9) و علامت اهم (U+2126)
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // سند زنده، ذخیره‌نشده
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // بعد از یک ذخیره و لود کامل
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

چرا وصله کردن CMap بعد از ذخیره کافی نبود

وصله کردن فایل ذخیره‌شده فقط همان فایل ذخیره‌شده را درست می‌کند، و آن هم فقط اگر وصله ساختار CMap را بایت‌به-بایت دست‌نخورده نگه دارد. fix اول، یعنی RepairSubsetToUnicodeCMaps در یونیت FPdfCompress، بعد از هر TPdf.SaveAs غیر-افزایشی اجرا می‌شود و هر CID متعارض را حل می‌کند: مدخل bfchar می‌برد، جفتی که فقط در بیت بالا فرق دارد به code point کوچک‌تر پایه-Latin حل می‌شود، و هر چیز دیگری نگاشت اولش را نگه می‌دارد

بخش جالب نتیجهٔ منفی است. بازسازی تمیز CMap متعارض، چه در فرم start-code و چه در فرم آرایه، حرکت بدیهی به نظر می‌رسید، و PDFium هر CMap بازسازی‌شده‌ای را یکسره رد می‌کرد و به Identity برمی‌گشت. تنها خروجی‌ای که reader بومی قبول کرد یک جایگزینی هم‌طول در-جا برای مقادیر hex متعارض بود، با چیدمان بلوک و پوشش CID دست‌نخورده. درس دوم فروتنانه‌تر بود: یادداشت ما در آن زمان مقصر حالت درون-حافظه را این می‌دانست که سند زنده اصلاً stream مربوط به ToUnicode ندارد. صدا زدن مستقیم DLL این را باطل کرد، چون سند زنده همان stream مبهم را حمل می‌کند، که یعنی fix واقعی باید قبل از اینکه PDFium اصلاً CMap را تولید کند اتفاق می‌افتاد. روتین ترمیم به‌عنوان پشتیبانی برای PDFهایی که ابزارهای دیگر مبتنی بر PDFium تولید کرده‌اند در کتابخانه می‌ماند

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // فقط ویرایش‌های هم‌طول؛ فایل‌های بدون تعارض قابل ترمیم،
      // و فایل‌های cross-reference-stream یا object-stream، عیناً کپی می‌شوند
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

کلید خوردن فونت با code point به‌جای گلیف

fix ریشه‌ای این است که کلاً از PDFium نخواهیم CMap را تولید کند. TPdf.LoadCachedFont حالا بایت‌های فونت سیستمی را به TPdf.LoadUnicodeKeyedCidFont می‌دهد که جدول cmap مربوط به sfnt خود فونت را می‌خواند، ترجیحاً یک subtable از فرم 12 و در نبودش فرم 4. code pointها مرتب و بدون تکرار برمی‌گردند، و CID با k+1 به code point با k تخصیص می‌یابد، با CID 0 که .notdef می‌ماند. یک CIDToGIDMap صریح هر CID را به گلیفش می‌فرستد، پس U+0020 و U+00A0 دو CID متفاوت می‌گیرند که همان outline را می‌کشند، و CMap مربوط به ToUnicode هر CID را فقط به یک code point نگاشت می‌کند. فونت بعد از طریق FPDFText_LoadCidType2Font لود می‌شود، همان نقطهٔ ورودی پشت نوشتن سطح-گلیف در جاسازی فونت CID Type 2 با نگاشت‌های صریح CID-to-GID

fix کلیدخورده-با-code-point در PDFium Component: LoadUnicodeKeyedCidFont جدول cmap مربوط به sfnt را می‌خواند، CID با k+1 را به هر code point مرتب‌شده تخصیص می‌دهد با CID 0 به‌عنوان notdef، یک CIDToGIDMap صریح وصل می‌کند تا U+0020 و U+00A0 دو CID متفاوت نگه دارند، و BuildUnicodeKeyedCidCMap به هر CID دقیقاً یک code point می‌دهد
NBSP و خط تیرهٔ نرم و علامت اهم بعداً زیر هر قاعدهٔ اولویتی به‌عنوان خودشان زنده می‌مانند، در سند زنده و بعد از هر ذخیره، پس ترمیم CMap چیزی برای درست کردن پیدا نمی‌کند
// فشرده‌شده از TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // یک CID، یک code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

وقتی FPDFText_SetText بعداً رشته‌ای را می‌نویسد، lookup معکوس روی یک CID تکی به‌ازای هر کاراکتر می‌افتد، پس NBSP و خط تیرهٔ نرم و علامت اهم هر یک زیر هر قاعدهٔ اولویتی به‌عنوان خودشان زنده می‌مانند، در حافظه و بعد از هر ذخیره. چون فایل ذخیره‌شده stream مربوط به ToUnicode خود کامپوننت را حمل می‌کند نه یکی که موتور تولید کرده، RepairSubsetToUnicodeCMaps در آن چیزی برای درست کردن پیدا نمی‌کند

چرا یک مدخل bfrange می‌تواند یک بلوک کامل را پاک کند؟

یک bfrange تکی که دنبالهٔ CID آن مرز یک xxFF را رد کند باعث می‌شود PDFium کل بلوکی را که درش نشسته دور بریزد. ISO 32000-1 §9.10.3 فقط اجازه می‌دهد بایت آخر مقصد داخل یک بازه عوض شود، ولی سمت CID تلهٔ خودش را دارد: HandleBeginBFRange مربوط به PDFium مقدار CID بالا را به‌صورت (low and $FFFFFF00) or (high and $FF) حساب می‌کند. دنباله‌ای از CID 00FE تا 0101 بنابراین 00FE تا 0001 خوانده می‌شود، یعنی پایین بزرگ‌تر از بالا، و کل بلوک نامعتبر علامت می‌خورد. شکست بی‌صداست: SetText موفق می‌شود، صفحه کاملاً درست رندر می‌شود، و استخراج برای هر کاراکتر آن بلوک U+0000 برمی‌گرداند

تلهٔ بی‌صدای bfrange در پارس CMap مربوط به PDF: دنباله‌ای از CID از 00FE تا 0101 مرز xxFF را رد می‌کند، HandleBeginBFRange مقدار CID بالا را 0001 حساب می‌کند، پایین بزرگ‌تر از بالا کل بلوک را نامعتبر علامت می‌زند، SetText و رندر همچنان موفق می‌شوند، و استخراج برای هر کاراکتر بلوک U+0000 برمی‌گرداند
BuildUnicodeKeyedCidCMap با تمام کردن هر دنباله قبل از بایت پایینی FF تله را دور می‌زند، بلوک‌ها را در سقف 100 مدخلی نگه می‌دارد و code pointهای صفحهٔ مکمل را به‌عنوان مدخل‌های تکی bfchar می‌نویسد

BuildUnicodeKeyedCidCMap یک دنباله را قبل از اینکه هم code point و هم CID به بایت پایینی FF برسند تمام می‌کند، هر بلوک را داخل سقف 100 مدخلی گرامر CMap نگه می‌دارد، و code pointهای صفحهٔ مکمل را به‌عنوان مدخل‌های تکی bfchar با مقصدهای جفت‌سوروگات UTF-16 می‌نویسد، چون افزایش دادن یک جفت سوروگات داخل یک بازه معنای تعریف‌شده‌ای ندارد؛ سمت سوروگات آن داستان در هندل کردن emoji و CJK و جفت‌سوروگات در Delphi است. یک CMap فقط-bfchar مشکل مرز را یکسره دور می‌زد، به قیمت چند برابر شدن حجم

فونت کلیدخورده-با-code-point چه چیزهایی را پوشش نمی‌دهد؟

مسیر کلیدخورده-با-code-point هر فونتی را که یک subtable یونیکد cmap افشا کند پوشش می‌دهد و برای بقیه به رفتار قدیمی کلیدخورده-با-گلیف برمی‌گردد. مرزهایی که قبل از اعتماد کردن به آن ارزش دانستن دارند:

  • فونت‌های symbol که فقط یک cmap از نوع (3,0) دارند، و هر فونتی که مسیر CID نتواند لودش کند، مثل قبل از FPDFText_LoadFont می‌روند، پس یک گلیف مشترک بین دو code point هنوز می‌تواند آنجا مبهم استخراج شود
  • بدون subtable فرم 12، نگاشت به BMP محدود می‌شود، و تعداد مدخل‌ها روی 65535 سقف می‌خورد تا هر CID در دو بایت بالای صفر جا شود
  • ذخیره‌های افزایشی (saIncremental) از روی طراحی RepairSubsetToUnicodeCMaps را skip می‌کنند، چون یک revision افزایشی باید فقط-append بماند؛ فونت‌های کلیدخورده-با-code-point برای متنی که خود کامپوننت می‌نویسد این را بی‌اهمیت می‌کنند
  • TrueType Collectionها نیاز به مراقبت بیشتر دارند: GetFontData مربوط به GDI کل .ttc را برمی‌گرداند، و FPDFText_LoadCidType2Font پارامتر اندیس face ندارد، پس درخواست NSimSun از simsun.ttc قبلاً SimSun یعنی face 0 را جاسازی و رندر می‌کرد. کامپوننت حالا نام خانواده را با جدول نام (nameID با 1 و 16) مچ می‌کند و face درخواستی را قبل از پارس cmap به‌عنوان یک sfnt مستقل بیرون می‌کشد؛ اگر پارس شکست بخورد، بایت‌های collection عبور می‌کنند و رفتار به face 0 برمی‌گردد

نوشتن متن و جاسازی فونت و استخراج یک مدل صفحه مشترک بین Delphi و C++Builder و Lazarus دارند، و API کامل در صفحهٔ محصول PDFium Component برای Delphi توصیف شده