PDFium Component за Delphi вгражда system шрифтовете, ползвани от TPdf.AddText, като CID шрифтове, ключвани по Unicode code point, така че всеки CID носи точно една ToUnicode карта. Именно това спира извлечените интервали да се връщат като U+00A0 (no-break space), а тиретата — като U+00AD (soft hyphen), както от живия документ, така и от записания файл
Симптомът е мерзък, защото е невидим. Search индекс пропускат „two-x“, защото съхраненият string съдържа soft hyphen, CSV export се разцепва другояче, diff инструмент маркира редове, които изглеждат идентични във всеки viewer. В рендерираната страница нищо не е наред; наред не е само Unicode-ът зад глифовете
Защо извлечените интервали се връщат като U+00A0?
Извлечените интервали се превръщат в U+00A0, защото ToUnicode CMap-ът, който PDFium генерира в FPDFText_LoadFont, е ключван по glyph, а до един glyph могат да стигнат два code point-а. В Arial glyph 3 обслужва и U+0020, и U+00A0, а тире glyph-ът — и U+002D, и U+00AD. Генерираният CMap затова картира един и същ CID два пъти, веднъж чрез запис bfchar и веднъж чрез bfrange в масивна форма, и кой запис ще надолее зависи от precedence правилото на четеца — той става извлеченият текст
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Дълго време това противоречие беше безвредно, понеже reader-ът на PDFium оставяше най-ниската карта да печели. Upstream промяна превключи reader-а към последният-печели и от този build нататък всеки интервал, записан с AddText, се извличаше като NBSP, а всяко тире — като soft hyphen. Обърнете внимание на модела в двойките: 0x20/0xA0 и 0x2D/0xAD се различават само по високия бит, точно каквото бихте очаквали от шрифт, чийто cmap праща Latin-1 двойници към един и същ outline. Ако кодът ви за извличане е бил наред вчера и днес се проваля върху невидими символи, изсипете code point-овете, вместо да вярвате на изгледа на дебъгера; основите на извличането на текст са разгледани в статията за извличане на текст от PDF документи с PDFium в Delphi
uses
SysUtils, PDFium;
const
// Space/U+00A0 и hyphen/U+00AD споделят един Arial glyph, както и
// гръцката Omega (U+03A9) и знакът Ohm (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 в unit-а FPdfCompress, върви след всяко не-incremental TPdf.SaveAs и resolve-ва всеки конфликтен CID: записът bfchar печели, двойка, различаваща се само по високия бит, се resolve-ва към по-малкия base-Latin code point, а всичко останало пази първата си карта
Интересната част е отрицателният резултат. Изграждането на конфликтния CMap наново, чисто, било в start-code или масивна форма, изглеждаше очевидният ход, а PDFium отхвърли всеки преизграден CMap тотално, падайки обратно на Identity. Единственият изход, който нативният reader прие, беше замяна на място с равна дължина на конфликтните hex стойности, с block подредба и CID покритие недокоснати. Вторият урок беше по-скромен: бележката ни по това време обвиняваше in-memory случая в това, че живият документ изобщо няма ToUnicode stream. Прякото викане на DLL опроверга това, тъй като живият документ носи същия двусмислен stream, което значеше, че истинският fix трябва да се случи, преди PDFium изобщо да е генерирал CMap-а. Repair рутината остава в библиотеката като защита за 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, се копират as-is
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Ключване на шрифта по code point вместо по glyph
Коренният fix е да спрете да молите PDFium изобщо да генерира CMap-а. TPdf.LoadCachedFont сега подава байтовете на system шрифта на TPdf.LoadUnicodeKeyedCidFont, който чете собствената sfnt cmap таблица на шрифта, предпочитайки format 12 под-таблица и падайки обратно на format 4. Code point-овете се връщат сортирани и дедупликирани, а CID k+1 се разпределя на k-тия code point, като CID 0 остава .notdef. Изричен CIDToGIDMap праща всеки CID към неговия glyph, така че U+0020 и U+00A0 получават два различни CID-а, рисуващи един и същ outline, а ToUnicode CMap-ът картира всеки CID към точно един code point. Шрифтът после се зарежда през FPDFText_LoadCidType2Font — същият входен пункт зад писането на ниво glyph в статията за 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 после запише string, обратното търсене каца на един-единствен CID на символ, така че NBSP, soft hyphen и знакът Ohm всеки оцелява такъв, какъвто е, под което и да е precedence правило, в паметта и след всеки запис. Понеже записаният файл носи собствения ToUnicode stream на компонента, а не engine-генериран, 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 — low по-голям от high — и целият блок се маркира невалиден. Провалът е мълчалив: SetText успява, страницата рендерира перфектно, а извличането връща U+0000 за всеки символ в този блок
BuildUnicodeKeyedCidCMap завършва протичане, преди code point-ът или CID-ът да достигне нисък байт FF, пази всеки блок в лимита от 100 записа на CMap граматиката и пише code point-ове от supplementary plane като отделни bfchar записи с UTF-16 surrogate-pair дестинации, тъй като нарастването на surrogate pair вътре в диапазон няма дефинирано значение; surrogate страната на тази история е в статията за emoji, CJK и surrogate pair обработката в Delphi. CMap само с bfchar би заобиколил проблема с границата изцяло, на няколко пъти по-голям размер
Какво не покрива шрифтът, ключван по code point?
Пътят с ключване по code point покрива всеки шрифт, излагащ Unicode cmap под-таблица, и пада обратно на старото поведение с ключване по glyph за останалите. Границите, полезни да знаете, преди да разчитате на него:
- Symbol шрифтове само с (3,0) cmap и всеки шрифт, който CID пътят не успее да зареди, минават през
FPDFText_LoadFontкакто преди, така че glyph, споделен от два code point-а, пак може да се извлече двусмислено там - Без format 12 под-таблица картата е ограничена до BMP, а броят записи има таван 65535, така че всеки CID се събира в два байта над нула
- Incremental записите (
saIncremental) прескачатRepairSubsetToUnicodeCMapsпо замисъл, защото incremental revision трябва да остане append-only; шрифтовете, ключвани по code point, правят това без значение за текста, който компонентът сам пише - TrueType Collections изискват допълнително внимание: GDI
GetFontDataвръща целия .ttc, аFPDFText_LoadCidType2Fontняма параметър face index, така че искането на NSimSun от simsun.ttc преди това вграждаше и рендерираше SimSun, face 0. Компонентът сега съпоставя family името срещу name таблицата (nameID 1 и 16) и извлича исканото лице като самостоятелен sfnt, преди cmap-ът да е парснат; при провал на парсването байтовете на колекцията минават през, а поведението се връща на face 0
Писането на текст, вграждането на шрифтове и извличането споделят един page модел между Delphi, C++Builder и Lazarus, а пълният API е описан на продуктовата страница на PDFium Component за Delphi