Техническа статия

ToUnicode fix: NBSP и soft hyphen в извлечен PDF текст

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 правилото на четеца — той става извлеченият текст

Защо един Arial glyph счупи извличането на PDF текст в Delphi: U+0020 и U+00A0 стигат glyph 3, а U+002D и U+00AD стигат тире glyph-а, така че генерираният ToUnicode CMap картира CID 0003 два пъти чрез bfchar запис и масивен bfrange, а precedence правилото на четеца решава кой code point се извлича
Precedence най-ниският-печели пазеше интервалите обикновени години наред, докато upstream смяната към последният-печели не накара всеки AddText интервал да се извлича като NBSP, а всяко тире — като soft hyphen
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 карти

Fix-ът с ключване по code point в PDFium Component: LoadUnicodeKeyedCidFont чете sfnt cmap на шрифта, разпределя CID k+1 на всеки сортиран code point с CID 0 като notdef, свързва изричен CIDToGIDMap, така че U+0020 и U+00A0 пазят различни CID-и, а BuildUnicodeKeyedCidCMap дава на всеки CID точно един code point
NBSP, soft hyphen и знакът Ohm после оцеляват такива, каквито са, под което и да е precedence правило, в живия документ и след всеки запис, така че 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 после запише 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 за всеки символ в този блок

Мълчаливият bfrange капан при парсване на PDF CMap: CID протичане от 00FE до 0101 пресича xxFF граница, HandleBeginBFRange извежда високия CID като 0001, low по-голям от high маркира целия блок невалиден, SetText и рендерирането продължават да успяват, а извличането връща U+0000 за всеки символ в блока
BuildUnicodeKeyedCidCMap избягва капана, като завършва всяко протичане преди нисък байт FF, пази блоковете в лимита от 100 записа и пише code point-ове от supplementary plane като отделни bfchar записи

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