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 ترجیحش بدهد متن استخراجشده میشود
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
// فشردهشده از 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 برمیگرداند
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 توصیف شده