Tekninen artikkeli

ToUnicode-korjaus: NBSP ja pehmeä väliviiva PDF:ssä

Delphin PDFium Component upottaa TPdf.AddTextin käyttämät järjestelmäfontit CID-fontteina jotka on avainattu Unicode-koodipisteellä, joten jokainen CID kantaa täsmälleen yhden ToUnicode-kuvauksen. Juuri se estää ekstrahoitujen välilyöntien palaamisen arvona U+00A0 (no-break space) ja väliviivojen arvona U+00AD (soft hyphen), sekä elävästä asiakirjasta että tallennetusta tiedostosta

Oire on ilkeä koska se on näkymätön. Hakemisto eksyy kohdasta ”two-x” koska tallennettu merkkijono sisältää pehmeän väliviivan, CSV-export jakautuu eri tavalla, diff-työkalu liputtaa rivejä jotka näyttävät identtisiltä jokaisessa katselimessa. Renderöidyltä sivulta ei puutu mitään; vain glyyfien takana oleva Unicode on pielessä

Miksi ekstrahoidut välilyönnit palaavat arvona U+00A0?

Ekstrahoidut välilyönnit muuttuvat arvoksi U+00A0 koska ToUnicode-CMap jonka PDFium generoi FPDFText_LoadFontissa on avainattu glyyfillä, ja yhteen glyyfiin pääsee kahdesta koodipisteestä. Arialissa glyyfi 3 palvelee sekä U+0020:a että U+00A0:a, ja väliviivaglyyfi palvelee sekä U+002D:tä että U+00AD:tä. Generoitu CMap kuvaa siksi saman CID:n kahdesti, kerran bfchar-merkinnän kautta ja kerran taulukkomuotoisen bfrangein kautta, ja kumpi tahansa merkintä jota lukijan etusijasääntö suosii muuttuu ekstrahoiduksi tekstiksi

Miksi yksi Arial-glyyfi rikkoi PDF-tekstiekstraktion Delphissä: U+0020 ja U+00A0 ulottuvat glyyfiin 3 ja U+002D ja U+00AD väliviivaglyyfiin, joten generoitu ToUnicode-CMap kuvaa CID:n 0003 kahdesti bfchar-merkinnän ja taulukkomuotoisen bfrangen kautta, ja lukijan etusijasääntö päättää mikä koodipiste ekstrahoituu
Pienin voittaa -etusijasääntö piti välilyönnit tavallisina vuosia, kunnes ylävirran siirtyminen viimeinen voittaa -käytäntöön sai jokaisen AddText-välilyönnin ekstrahoitumaan NBSP:nä ja jokaisen väliviivan pehmeänä väliviivana
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Pitkään tämä ristiriita oli harmiton, koska PDFiumin lukija antoi pienimmän kuvauksen voittaa. Ylävirran muutos käänsi lukijan viimeinen voittaa -käytäntöön, ja siitä käännöksestä alkaen jokainen AddTextilla kirjoitettu välilyönti ekstrahoitui NBSP:nä ja jokainen väliviiva pehmeänä väliviivana. Huomaa kaava pareittain: 0x20/0xA0 ja 0x2D/0xAD eroavat vain korkeimmassa bitissä, mikä on juuri sitä mitä voi odottaa fontilta jonka cmap lähettää Latin-1-kaksoiset samaan ääriviivaan. Jos ekstraktiokoodisi toimi eilen ja epäonnistuu nyt näkymättömissä merkeissä, vedosta koodipisteet sen sijaan että luottaisit debuggaajan näkymään; tekstin ulos vetämisen perusteet käsitellään artikkelissa tekstin ekstrahointi PDF-asiakirjoista PDFiumilla Delphissä

uses
  SysUtils, PDFium;

const
  // Välilyönti/U+00A0 ja väliviiva/U+00AD jakavat yhden Arial-glyyfin, samoin
  // kreikkalainen Omega (U+03A9) ja Ohm-merkki (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;                  // elävä, tallentamaton asiakirja
    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;              // täyden tallennuksen ja uudelleenlatauksen jälkeen
  finally
    Pdf.Free;
  end;

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

Miksi CMapin paikkaaminen tallennuksen jälkeen ei riittänyt

Tallennetun tiedoston paikkaaminen korjaa vain tallennetun tiedoston, ja vain jos paikkaus pitää CMapin rakenteen tavu tavulta ennallaan. Ensimmäinen korjaus, RepairSubsetToUnicodeCMaps FPdfCompress-yksikössä, ajetaan jokaisen ei-inkrementaalisen TPdf.SaveAsin jälkeen ja ratkaisee jokaisen ristiriitaisen CID:n: bfchar-merkintä voittaa, pari joka eroaa vain korkeimmassa bitissä ratkeaa pienempään base-Latin-koodipisteeseen, ja kaikki muu säilyttää ensimmäisen kuvauksensa

Kiinnostava osa on negatiivinen tulos. Ristiriitaisen CMapin rakentaminen puhtaasti uudelleen, joko start-code- tai taulukkomuodossa, näytti ilmeiseltä siirrolta, ja PDFium hylkäsi jokaisen uudelleenrakennetun CMapin täysin palaten Identityyn. Ainoa tuotos jota natiivilukija hyväksyi oli samanpituisena paikallaan tapahtuva korvaus ristiriitaisia heksa-arvoja, lohkoasettelun ja CID-kattavuuden ollessa koskemattomia. Toinen opetus oli nöyrempi: muistiinpanomme syytti silloin muistissa olevaa tapausta siitä ettei elävällä asiakirjalla ollut lainkaan ToUnicode-streamia. DLL:n suora kutsuminen kumosi sen, koska elävä asiakirja kantaa samaa monimerkityksistä streamia, mikä tarkoitti että oikean korjauksen oli tapahduttava ennen kuin PDFium ylipäätään generoi CMapin. Korjausrutiini jää kirjastoon puolustukseksi muiden PDFium-pohjaisten työkalujen tuottamia PDF:ejä vastaan

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
      // Vain samanpituiset muokkaukset; tiedostot ilman korjattavaa ristiriitaa,
      // sekä ristiviittausstream- tai objektistream-tiedostot kopioidaan sellaisenaan
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Fontin avainaminen koodipisteellä glyyfin sijaan

Juurikorjaus on lopettaa PDFiumilta CMapin generoinnin pyytäminen ylipäätään. TPdf.LoadCachedFont luovuttaa nyt järjestelmäfontin tavut TPdf.LoadUnicodeKeyedCidFontille, joka lukee fontin oman sfnt cmap-taulun suosien format 12 -alitaulua ja palaten format 4:ään. Koodipisteet palaavat lajiteltuina ja tupliana poistettuina, ja CID k+1 osoitetaan k:nnelle koodipisteelle, CID 0:n jäädessä .notdefiksi. Eksplisiittinen CIDToGIDMap lähettää jokaisen CID:n glyyfilleen, joten U+0020 ja U+00A0 saavat kaksi eri CID:iä jotka piirtävät saman ääriviivan, ja ToUnicode-CMap kuvaa jokaisen CID:n vain yhteen koodipisteeseen. Fontti ladataan sitten FPDFText_LoadCidType2Fontin kautta, sama sisääntulopiste glyyfitason kirjoituksen takana artikkelissa CID Type 2 -fonttien upotus eksplisiittisillä CID-GID-kuvauksilla

Koodipisteellä avainattu korjaus PDFium Componentissa: LoadUnicodeKeyedCidFont lukee fontin sfnt cmapin, osoittaa CID k+1 jokaiselle lajitellulle koodipisteelle CID 0:n ollessa notdef, kytkee eksplisiittisen CIDToGIDMapin joten U+0020 ja U+00A0 säilyttävät eri CID:t, ja BuildUnicodeKeyedCidCMap antaa jokaiselle CID:lle täsmälleen yhden koodipisteen
NBSP, pehmeä väliviiva ja Ohm-merkki selviävät sitten itsenään kummalla tahansa etusijasäännöllä, elävässä asiakirjassa ja minkä tahansa tallennuksen jälkeen, joten CMap-korjaus ei löydä enää mitään korjattavaa
// Tiivistetty funktiosta 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);       // yksi CID, yksi koodipiste
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Kun FPDFText_SetText myöhemmin kirjoittaa merkkijonon, käänteinen haku osuu yhteen CID:iin per merkki, joten NBSP, pehmeä väliviiva ja Ohm-merkki selviävät kukin itsenään kummalla tahansa etusijasäännöllä, muistissa ja minkä tahansa tallennuksen jälkeen. Koska tallennettu tiedosto kantaa komponentin omaa ToUnicode-streamia moottorin generoiman sijaan, RepairSubsetToUnicodeCMaps ei löydä siitä mitään korjattavaa

Miksi yksi bfrange-merkintä voi pyyhkiä kokonaisen lohkon?

Yksittäinen bfrange jonka CID-ajo ylittää xxFF-rajan saa PDFiumin hylkäämään kokonaisen lohkon jonka sisällä se istuu. ISO 32000-1 §9.10.3 antaa vain kohteen viimeisen tavun vaihdella alueen sisällä, mutta CID-puolella on oma ansansa: PDFiumin HandleBeginBFRange johtaa korkean CID:n muodossa (low and $FFFFFF00) or (high and $FF). Ajo CID:stä 00FE arvoon 0101 luetaan siksi muodossa 00FE arvoon 0001, pienempi suurempi, ja koko lohko merkitään kelvottomaksi. Vika on hiljainen: SetText onnistuu, sivu renderöityy täydellisesti, ja ekstraktio palauttaa arvon U+0000 jokaiselle kyseisen lohkon merkille

Hiljainen bfrange-ansa PDF:n CMapin jäsennyksessä: CID-ajo arvosta 00FE arvoon 0101 ylittää xxFF-rajan, HandleBeginBFRange johtaa korkean CID:n arvoksi 0001, pienempi kuin suurempi merkitsee koko lohkon kelvottomaksi, SetText ja renderöinti yhä onnistuvat, ja ekstraktio palauttaa arvon U+0000 jokaiselle lohkon merkille
BuildUnicodeKeyedCidCMap välttää ansan päättämällä jokaisen ajon ennen FF:n alatavua, pitämällä lohkot CMapin kieliopin 100 merkinnän rajan sisällä ja kirjoittamalla täydentävien tasojen koodipisteet yksittäisinä bfchar-merkintöinä

BuildUnicodeKeyedCidCMap päättää ajon ennen kuin koodipiste tai CID saavuttaa FF:n alatavun, pitää jokaisen lohkon CMapin kieliopin 100 merkinnän rajan sisällä ja kirjoittaa täydentävien tasojen koodipisteet yksittäisiksi bfchar-merkinnöiksi UTF-16-surrogaariparein kohteina, koska surrogaariparin kasvattamisella alueen sisällä ei ole määriteltyä merkitystä; surrogaaripuoli siitä tarinasta on artikkelissa emoji, CJK ja surrogaariparien käsittely Delphissä. Pelkkä bfchar-CMap kiertäisi rajan ongelman kokonaan, useankertaisella koolla

Mitä koodipisteellä avainattu fontti ei kata?

Koodipisteellä avainattu polku kattaa jokaisen fontin joka paljastaa Unicode-cmap-alitaulun, ja palaa vanhaan glyyfillä avainattuun käyttäytymiseen muille. Rajat jotka kannattaa tuntea ennen kuin luotat siihen:

  • Symbolfontit joilla on vain (3,0)-cmap, ja mikä tahansa fontti jonka CID-polku ei lataudu, kulkevat FPDFText_LoadFontin kautta kuten ennenkin, joten kahden koodipisteen jakama glyyfi voi yhä ekstrahoitua monimerkityksisesti siellä
  • Ilman format 12 -alitaulua kuvaus rajoittuu BMP:hen, ja merkintöjen määrä katkaistaan arvoon 65535 jotta jokainen CID mahtuu kahteen tavuun nollan yläpuolella
  • Inkrementaaliset tallennukset (saIncremental) ohittavat RepairSubsetToUnicodeCMapsin suunnitellusti, koska inkrementaalisen revision on pysyttävä vain liittävänä; koodipisteellä avainatut fontit tekevät siitä merkityksettömän tekstille jota komponentti itse kirjoittaa
  • TrueType Collections vaativat lisähuomiota: GDI:n GetFontData palauttaa koko .ttc:n, ja FPDFText_LoadCidType2Fontilla ei ole face-indeksiparametria, joten NSimSunin pyytäminen simsun.ttc:stä upotti ja renderöi aiemmin SimSunin, facen 0. Komponentti vertaa nyt perhenimeä name-tauluun (nameID 1 ja 16) ja ekstrahoi pyydetyn facen itsenäiseksi sfntiksi ennen kuin cmap jäsentyy; jos jäsennys epäonnistuu, kokoelman tavut kulkevat läpi ja käytös palaa faceen 0

Tekstin kirjoittaminen, fonttien upotus ja ekstraktio jakavat yhden sivumallin Delphissä, C++Builderissa ja Lazaruksessa, ja koko API on kuvattu PDFium Component for Delphi -tuotesivulla