Műszaki cikk

ToUnicode-javítás: NBSP és lágy kötőjel kinyerés Delphiben

A Delphihez készült PDFium Component a TPdf.AddText által használt rendszerfontokat CID fontokként ágyazza be, Unicode kódpont szerint kulcsolva, így minden CID pontosan egy ToUnicode leképezést hordoz. Ez az, ami megakadályozza, hogy a kinyert szóközök U+00A0-ként (nem törhető szóköz) és a kötőjelek U+00AD-ként (lágy kötőjel) jöjjenek vissza, egyszerre az élő dokumentumból és a mentett fájlból

A tünet kellemetlen, mert láthatatlan. Egy keresési index nem találja a „two-x"-et, mert a tárolt string lágy kötőjelet tartalmaz, egy CSV export másképp bont, egy diff eszköz olyan sorokat jelöl, amelyek minden nézegetőben azonosnak tűnnek. A kirajzolt oldalon semmi nincs elrontva; csak a jelek mögötti Unicode az

Miért térnek vissza a kinyert szóközök U+00A0-ként?

A kinyert szóközök U+00A0-vá változnak, mert a PDFium által az FPDFText_LoadFont-ban generált ToUnicode CMap jel (glyph) szerint van kulcsolva, és egy jelet két kódpont is elérhet. Az Arialban a 3-as jel az U+0020-et és az U+00A0-t is kiszolgálja, a kötőjel jele pedig az U+002D-t és az U+00AD-t. A generált CMap ezért ugyanazt a CID-et kétszer képezi le, egyszer egy bfchar bejegyzésen, egyszer egy tömbalakú bfrange-en keresztül, és amelyik bejegyzést az olvasó elsőbbségi szabálya előnyben részesíti, az lesz a kinyert szöveg

Miért tört el a PDF szövegkinyerés Delphiben egyetlen Arial jel miatt: az U+0020 és az U+00A0 a 3-as jelet éri el, az U+002D és az U+00AD a kötőjel jelet, így a generált ToUnicode CMap a 0003-as CID-et kétszer képezi le, egy bfchar bejegyzésen és egy tömb bfrange-en keresztül, és az olvasó elsőbbségi szabálya dönti el, melyik kódpont nyerhető ki
A legalacsonyabb-nyer elsőbbségi szabály éveken át simán hagyta a szóközöket, míg egy upstream váltás az utolsó-nyer szabályra minden AddText szóközt NBSP-ként és minden kötőjelet lágy kötőjelként nyert ki
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Sokáig ez az ellentmondás ártalmatlan volt, mivel a PDFium olvasója a legalacsonyabb leképezést hagyta nyerni. Egy upstream változás az utolsó-nyer szabályra kapcsolta az olvasót, és attól a buildtől kezdve minden, AddText-tel írt szóköz NBSP-ként, minden kötőjel lágy kötőjelként nyert ki. Figyeljen a párok mintázatára: a 0x20/0xA0 és a 0x2D/0xAD csak a felső bitben tér el, ami pontosan azt jelzi, amire egy olyan fonttól várhatunk, amelynek cmap-je a Latin-1 hasonmásokat ugyanarra a kontúrra küldi. Ha a kinyerő kódja tegnap még jó volt, és most láthatatlan karaktereken bukik, dumpolja a kódpontokat ahelyett, hogy a debugger nézetére hagyatkozna; a szöveg kihúzásának alapjait a szöveg kinyerése PDF dokumentumokból PDFiummal Delphiben cikk fedi le

uses
  SysUtils, PDFium;

const
  // A szóköz/U+00A0 és a kötőjel/U+00AD egy Arial jelet oszt meg,
  // akárcsak a görög Omega (U+03A9) és az Ohm jel (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;                  // élő, mentetlen dokumentum
    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;              // teljes mentés és újratöltés után
  finally
    Pdf.Free;
  end;

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

Miért nem volt elég a CMap foltozása mentés után

A mentett fájl foltozása csak a mentett fájlt javítja, és csak akkor, ha a foltozás a CMap struktúrát bájtonkénti épségben tartja. Az első javítás, a FPdfCompress egység RepairSubsetToUnicodeCMaps-e minden nem inkrementális TPdf.SaveAs után fut, és minden ütköző CID-et felold: a bfchar bejegyzés nyer, egy párt, amely csak a felső bitben tér el, a kisebb alap-Latin kódpont old fel, minden más megtartja az első leképezését

Az érdekes rész a negatív eredmény. Az ütköző CMap tiszta újjáépítése, akár start-kód, akár tömb alakban, kézenfekvő lépésnek tűnt, és a PDFium minden újjáépített CMap-ot elutasított, Identity-re esve vissza. Az egyetlen kimenet, amelyet a natív olvasó elfogadott, az ütköző hex értékek egyenlő hosszú, helyben végzett cseréje volt, blokkelrendezés és CID lefedettség érintetlenül. A második tanulság szerényebb volt: akkori jegyzetünk a memóriabeli esetet arra fogta, hogy az élő dokumentumnak egyáltalán nincs ToUnicode folyamja. A DLL közvetlen meghívása ezt megcáfolta, hiszen az élő dokumentum ugyanazt a kétértelmű folyamot hordozza, ami azt jelentette, hogy a valódi javításnak még azelőtt kellett megtörténnie, hogy a PDFium egyáltalán legenerálná a CMap-ot. A javító rutin a könyvtárban marad védelemként más PDFium-alapú eszközök által készített PDF-ekhez

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
      // Csak egyenlő hosszú szerkesztések; a javítható ütközés nélküli fájlok,
      // valamint a kereszthivatkozási- vagy objektumfolyamos fájlok, változatlanul másolódnak
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

A font kulcsolása jel helyett kódpont szerint

A gyökérjavítás az, hogy egyáltalán nem kérjük a PDFiumtól a CMap generálását. A TPdf.LoadCachedFont mostantól a rendszerfont bájtjait a TPdf.LoadUnicodeKeyedCidFont-nak adja, amely a font saját sfnt cmap tábláját olvassa, előnyben részesítve egy 12-es formátumú altáblát, és 4-es formátumra esve vissza. A kódpontok rendezve és duplikátummentesen jönnek vissza, a CID k+1 a k-adik kódpontot kapja, a CID 0 pedig .notdef marad. Egy explicit CIDToGIDMap minden CID-et a jeléhez köt, tehát az U+0020 és az U+00A0 két különböző CID-et kap, amelyek ugyanazt a kontúrt rajzolják, és a ToUnicode CMap minden CID-et egyetlen kódpontra képez le. A font ezután az FPDFText_LoadCidType2Font-on keresztül töltődik, ugyanazon a belépési ponton, amely a CID Type 2 font beágyazás mögött explicit CID-GID leképezésekkel áll a jel szintű írás hátterében

A kódpont szerint kulcsolt javítás a PDFium Component-ben: a LoadUnicodeKeyedCidFont a font sfnt cmap-jét olvassa, CID k+1-et rendel minden rendezett kódponthoz, CID 0 notdef, explicit CIDToGIDMap-ot köt be, így az U+0020 és az U+00A0 különböző CID-eket tart, a BuildUnicodeKeyedCidCMap pedig minden CID-nek pontosan egy kódpontot ad
Az NBSP, a lágy kötőjel és az Ohm jel így bármely elsőbbségi szabály alatt önmaguként él túl, az élő dokumentumban és bármely mentés után, tehát a CMap javítója nem talál már mit foltozni
// A TPdf.LoadUnicodeKeyedCidFont-ból sűrítve
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);       // egy CID, egy kódpont
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Amikor a FPDFText_SetText később stringet ír, a visszirányú keresés karakterenként egyetlen CID-re ér, tehát az NBSP, a lágy kötőjel és az Ohm jel egyaránt önmagaként él túl bármely elsőbbségi szabály alatt, memóriában és bármely mentés után. Mivel a mentett fájl a komponens saját ToUnicode folyamát hordozza a motor által generált helyett, a RepairSubsetToUnicodeCMaps nem talál benne mit javítani

Hogyan törölhet ki egyetlen bfrange bejegyzés egy egész blokkot?

Egyetlen bfrange, amelynek CID futása átlép egy xxFF határon, arra készteti a PDFiumot, hogy eldobja azt a teljes blokkot, amelyben ül. Az ISO 32000-1 §9.10.3 csak azt engedi, hogy a cél utolsó bájtja változzon a tartományon belül, de a CID oldalon is van csapda: a PDFium HandleBeginBFRange-je a magas CID-et (low and $FFFFFF00) or (high and $FF) alakban származtatja. Egy 00FE-től 0101-ig futó futás ezért 00FE-től 0001-ig olvasódik, az alsó nagyobb a felsőnél, és az egész blokk érvénytelennek jelölődik. A hiba csendes: a SetText sikerül, az oldal tökéletesen rajzol, és a kinyerés a blokk minden karakterére U+0000-t ad vissza

A csendes bfrange csapda a PDF CMap elemzésében: egy 00FE-től 0101-ig futó CID-futás átlépi az xxFF határt, a HandleBeginBFRange a magas CID-et 0001-ként származtatja, az alsó-nagyobb-a-felsőnél az egész blokkot érvénytelennek jelöli, a SetText és a rajzolás továbbra is sikerül, a kinyerés pedig a blokk minden karakterére U+0000-t ad vissza
A BuildUnicodeKeyedCidCMap elkerüli a csapdát úgy, hogy minden futást FF alacsony bájtja előtt zár le, a blokkokat a 100 bejegyzéses határon belül tartja, a kiegészítő síkbeli kódpontokat pedig egyenként bfchar bejegyzésként írja

A BuildUnicodeKeyedCidCMap minden futást lezár, még mielőtt a kódpont vagy a CID FF alacsony bájtjához érne, minden blokkot a CMap nyelvtan 100 bejegyzéses határán belül tart, a kiegészítő síkbeli kódpontokat pedig egyenként bfchar bejegyzésként írja UTF-16 szürrogátpár célokkal, mivel egy szürrogátpár növelése egy tartományon belül nincs definiálva; ennek a történetnek a szürrogát oldala a emoji, CJK és szürrogátpár kezelés Delphiben cikkben olvasható. Egy csak bfchar-os CMap teljesen elkerülné a határproblémát, jóval nagyobb méret árán

Mit nem fed le a kódpont szerint kulcsolt font?

A kódpont szerint kulcsolt út minden olyan fontot lefed, amely Unicode cmap altáblát tár fel, és a többinél a régi, jel szerinti viselkedésre esik vissza. A határok, amelyeket érdemes ismerni, mielőtt ráépítene:

  • A csak (3,0) cmap-mel rendelkező symbol fontok, és minden font, amelyet a CID út nem tud betölteni, a régi módon az FPDFText_LoadFont-on mennek át, tehát ott két kódpont által megosztott jel továbbra is kétértelműen nyerhető ki
  • 12-es formátumú altábla nélkül a leképezés a BMP-re korlátozódik, és a bejegyzésszám 65535-nél tetőzik, hogy minden CID két bájtban, nullától felfelé elférjen
  • Az inkrementális mentések (saIncremental) tervezésből kihagyják a RepairSubsetToUnicodeCMaps-t, mert egy inkrementális revíziónak csak hozzáfűzőnek kell maradnia; a kódpont szerint kulcsolt fontok ezt irrelevánssá teszik azokra a szövegekre, amelyeket maga a komponens ír
  • A TrueType Collection-ök külön gondoskodást igényelnek: a GDI GetFontData-ja a teljes .ttc-t adja vissza, az FPDFText_LoadCidType2Font-nak pedig nincs face index paramétere, így az NSimSun kérése a simsun.ttc-ből korábban SimSun-t, a 0. facét ágyazta be és rajzolta. A komponens mostantól a családnevet a névtáblához (nameID 1 és 16) illeszti, és a kért facét önálló sfnt-ként nyeri ki, mielőtt a cmap elemzése megtörténne; ha az elemzés elbukik, a kollekció bájtjai átmegy, és a viselkedés a 0. facára esik vissza

A szövegírás, a font beágyazás és a kinyerés egyetlen oldalmodellen osztozik Delphiben, C++Builderben és Lazarusban egyaránt, a teljes API pedig a PDFium Component Delphihez termékoldalon van leírva