Techninis straipsnis

ToUnicode pataisa: NBSP ir soft hyphen PDF ištraukoje

PDFium Component for Delphi įdeda TPdf.AddText naudojamus sistemos šriftus kaip CID šriftus, raktuojamus Unicode kodo tašku, tad kiekvienas CID neša lygiai vieną ToUnicode atvaizdavimą. Būtent todėl ištraukti tarpai nebegrįžta kaip U+00A0 (nepertraukiamasis tarpas), o brūkšneliai — kaip U+00AD (minkštasis brūkšnelis), tiek gyvame dokumente, tiek išsaugotame faile

Simptomas baisus, nes nematomas. Paieškos indeksas praleidžia „two-x“, nes saugomoje eilutėje yra minkštasis brūkšnelis, CSV eksportas skyla kitaip, diff įrankis žymi eilutes, kurios kiekvienoje žiūryklėje atrodo identiškai. Atvaizduotame puslapyje niekas negerai; netinkamas tik Unicode už glifų

Kodėl ištraukti tarpai grįžta kaip U+00A0?

Ištraukti tarpai virsta U+00A0, nes ToUnicode CMap, kurį PDFium sugeneruoja FPDFText_LoadFont, raktuojamas glifu, o prie vieno glifo galima prieiti iš dviejų kodo taškų. Arial glifas 3 aptarnauja ir U+0020, ir U+00A0, o brūkšnelio glifas — ir U+002D, ir U+00AD. Sugeneruotas CMap todėl tą patį CID atvaizduoja du kartus — kartą per bfchar įrašą ir kartą per masyvinės formos bfrange — ir tas įrašas, kurį pasirenka skaitytuvo pirmenybės taisyklė, tampa ištrauktu tekstu

Kodėl vienas Arial glifas sugriovė PDF teksto ištrauką Delphi: U+0020 ir U+00A0 pasiekia glifą 3, o U+002D ir U+00AD — brūkšnelio glifą, tad sugeneruotas ToUnicode CMap CID 0003 atvaizduoja du kartus per bfchar įrašą ir masyvinį bfrange, o skaitytuvo pirmenybės taisyklė nusprendžia, kuris kodo taškas ištraukiamas
Mažiausios-laimi pirmenybė metų metus laikė tarpus paprastais, kol aukščiau atsiradęs perjungimas į last-wins padarė, kad kiekvienas AddText tarpas ištraukiamas kaip NBSP, o kiekvienas brūkšnelis — kaip minkštasis brūkšnelis
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Ilgą laiką šis prieštaravimas buvo nekenksmingas, nes PDFium skaitytuvas leisdavo mažiausiam atvaizdavimui laimėti. Pakeitimas aukščiau persjungė skaitytuvą į last-wins, ir nuo to build'o kiekvienas AddText parašytas tarpas ištraukiamas kaip NBSP, o kiekvienas brūkšnelis — kaip minkštasis brūkšnelis. Pastebėkite modelį porose: 0x20/0xA0 ir 0x2D/0xAD skiriasi tik aukštesniuoju bitu, ir būtent to tikėtumėsi iš šrifto, kurio cmap lotyniškus atitikmenis siunčia į tą patį kontūrą. Jei jūsų ištraukos kodas vakar buvo geras, o dabar klysta dėl nematomų simbolių, išspausdinkite kodo taškus, užuot pasitikėję derintuvės rodiniu; teksto ištraukimo pagrindus dengia teksto ištraukimas iš PDF dokumentų su PDFium Delphi

uses
  SysUtils, PDFium;

const
  // Tarpas/U+00A0 ir brūkšnelis/U+00AD dalijasi vienu Arial glifu, taip pat
  // graikų Omega (U+03A9) ir Omo ženklas (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;                  // gyvas, neišsaugotas dokumentas
    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;              // po pilno išsaugojimo ir pakartotinio įkėlimo
  finally
    Pdf.Free;
  end;

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

Kodėl CMap lopymas po išsaugojimo nebuvo pakankamas

Išsaugoto failo lopymas sutvarko tik išsaugotą failą, ir tik jei lopas CMap struktūrą palieka baitas į baitą nesugadintą. Pirmoji pataisa, RepairSubsetToUnicodeCMaps FPdfCompress modulyje, paleidžiama po kiekvieno ne inkrementinio TPdf.SaveAs ir išsprendžia kiekvieną konfliktuojantį CID: bfchar įrašas laimi, pora, besiskirianti tik aukštesniuoju bitu, išsprendžiama į mažesnįjį bazinį lotynų kodo tašką, o visa kita išlaiko pirmąjį atvaizdavimą

Įdomiausia dalis — neigiamas rezultatas. Konfliktuojančio CMap tvarkingas perstatymas, start-code arba masyvo forma, atrodė kaip akivaizdus žingsnis, ir PDFium atmetė kiekvieną perstatytą CMap iš karto, nusileisdamas į Identity. Vienintelė išvestis, kurią savoji skaitytojas priėmė, buvo vienodo ilgio vietinė konfliktuojančių heksų reikšmių pakeitimas, su nepaliestu bloko išdėstymu ir CID danga. Antra pamoka buvo kuklesnė: mūsų to meto pastaba kaltino atminties atvejį tuo, kad gyvas dokumentas apskritai neturi ToUnicode srauto. Tiesioginis DLL kvietimas tai paneigė, nes gyvas dokumentas neša tą patį dviprasmišką srautą — tai reiškė, kad tikroji pataisa turi įvykti dar prieš PDFium sugeneruojant CMap. Taisymo procedūra lieka bibliotekoje kaip gynyba nuo kitų ant PDFium pagrindo veikiančių įrankių pagamintų PDF

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
      // Tik vienodo ilgio pakeitimai; failai be taisomo konflikto,
      // taip pat kryžminių ar objektų srautų failai, kopijuojami tokie, kokie yra
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Šrifto raktavimas pagal kodo tašką vietoj glifo

Šakninė pataisa — apskritai nustoti prašyti PDFium generuoti CMap. TPdf.LoadCachedFont dabar sistemos šrifto baitus atiduoda TPdf.LoadUnicodeKeyedCidFont, kuri skaito paties šrifto sfnt cmap lentelę, teikdama pirmenybę 12 formato sublentelei ir nusileisdama į 4 formatą. Kodo taškai grįžta surūšiuoti ir be dublių, o CID k+1 priskiriamas k-tajam kodo taškui, CID 0 paliekamas .notdef. Aiški CIDToGIDMap kiekvieną CID nukreipia į jo glifą, tad U+0020 ir U+00A0 gauna du skirtingus CID, piešiančius tą patį kontūrą, o ToUnicode CMap kiekvieną CID atvaizduoja tik į vieną kodo tašką. Tada šriftas įkeliamas per FPDFText_LoadCidType2Font — tą patį įėjimo tašką, slypintį už glifo lygio rašymo CID Type 2 šriftų įdėjime su aiškiais CID-į-GID atvaizdais

Kodo tašku raktuota pataisa PDFium Component: LoadUnicodeKeyedCidFont skaito šrifto sfnt cmap, kiekvienam surūšiuotam kodo taškui priskiria CID k+1 su CID 0 kaip notdef, sujungia aiškią CIDToGIDMap, tad U+0020 ir U+00A0 išlaiko skirtingus CID, o BuildUnicodeKeyedCidCMap kiekvienam CID duoda lygiai vieną kodo tašką
NBSP, minkštasis brūkšnelis ir Omo ženklas tada išgyvena kaip patys, esant bet kuriai pirmenybės taisyklei, gyvame dokumente ir po bet kokio išsaugojimo, tad CMap taisymas nebemato, ką taisyti
// Sutrumpinta iš 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);       // vienas CID, vienas kodo taškas
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Kai FPDFText_SetText vėliau rašo eilutę, atvirkštinė paieška nusileidžia ant vieno CID simboliui, tad NBSP, minkštasis brūkšnelis ir Omo ženklas kiekvienas išgyvena kaip patys, esant bet kuriai pirmenybės taisyklei, atmintyje ir po bet kokio išsaugojimo. Kadangi išsaugotas failas neša paties komponento ToUnicode srautą, o ne variklio sugeneruotą, RepairSubsetToUnicodeCMaps jame nebemato, ką taisyti

Kodėl vienas bfrange įrašas gali nušluoti visą bloką?

Vienas bfrange, kurio CID seka kerta xFF ribą, priverčia PDFium išmesti visą bloką, kuriame jis sėdi. ISO 32000-1 §9.10.3 leidžia kisti tik paskutiniam tikslo baitui ruože, bet CID pusė turi savus spąstus: PDFium HandleBeginBFRange aukštąjį CID išveda kaip (low and $FFFFFF00) or (high and $FF). Seka nuo CID 00FE iki 0101 todėl skaitoma kaip 00FE iki 0001 — žemas didesnis už aukštą, ir visas blokas pažymimas netinkamu. Nesėkmė tyli: SetText pavyksta, puslapis atvaizduojamas tobulai, o ištrauka grąžina U+0000 kiekvienam to bloko simboliui

Tylūs bfrange spąstai skaitant PDF CMap: CID seka nuo 00FE iki 0101 kerta xFF ribą, HandleBeginBFRange aukštąjį CID išveda kaip 0001, žemas didesnis už aukštą pažymi visą bloką netinkamu, SetText ir atvaizdavimas tebepavyksta, o ištrauka grąžina U+0000 kiekvienam bloko simboliui
BuildUnicodeKeyedCidCMap spąstų vengia baigdamas kiekvieną seką iki žemojo FF baito, laiko blokus 100 įrašų riboje ir papildomųjų plokštumų kodo taškus rašo kaip atskirus bfchar įrašus

BuildUnicodeKeyedCidCMap seką užbaigia dar prieš kodo taškui ar CID pasiekus žemąjį FF baitą, laiko kiekvieną bloką CMap gramatikos 100 įrašų riboje ir papildomųjų plokštumų kodo taškus rašo kaip atskirus bfchar įrašus su UTF-16 surrogate porų tikslais, nes surrogate poros didinimas ruožo viduje neturi apibrėžtos reikšmės; surrogate pusė tos istorijos — emoji, CJK ir surrogate porų apdorojime Delphi. Vien tik bfchar CMap ribų problemą apeitų visai, tik kelis kartus didesnio dydžio

Ko kodo tašku raktuotas šriftas nedengia?

Kodo tašku raktuotas kelias dengia kiekvieną šriftą, atveriantį Unicode cmap sublentelę, o likusiems nusileidžia į senąjį glifu raktuotą elgesį. Ribos, vertos žinoti, prieš jumis pasitikint juo:

  • Simbolių šriftai tik su (3,0) cmap ir bet koks šriftas, kurio CID kelias nesugeba įkelti, eina per FPDFText_LoadFont kaip anksčiau, tad glifas, dalijamas dviejų kodo taškų, ten vis tiek gali ištraukti dviprasmiškai
  • Be 12 formato sublentelės atvaizdas ribojamas BMP, o įrašų skaičius ribojamas 65535, kad kiekvienas CID teltų į du baitus virš nulio
  • Inkrementiniai įrašymai (saIncremental) praleidžia RepairSubsetToUnicodeCMaps sąmoningai, nes inkrementinė revizija privalo likti tik prijungiamąja; kodo tašku raktuoti šriftai tai daro nereikšmingą tekstui, kurį rašo pats komponentas
  • TrueType Collections reikalauja papildomos priežiūros: GDI GetFontData grąžina visą .ttc, o FPDFText_LoadCidType2Font neturi face indekso parametro, tad NSimSun prašymas iš simsun.ttc anksčiau įdėdavo ir atvaizduodavo SimSun, face 0. Komponentas dabar šeimos vardą sugretina su name lentele (nameID 1 ir 16) ir ištraukia prašomąjį face kaip savarankišką sfnt, dar neskanuojant cmap; jei skaitymas žlunga, kolekcijos baitai praeina toliau, o elgesys grįžta į face 0

Teksto rašymas, šriftų įdėjimas ir ištrauka dalijasi vienu puslapio modeliu tarp Delphi, C++Builder ir Lazarus, o visa API aprašyta PDFium Component for Delphi produkto puslapyje