Teknisk artikkel

ToUnicode-fiks: NBSP og myk bindestrek i PDF-tekstuttrekk

PDFium Component for Delphi legger inn systemfontene som TPdf.AddText bruker, som CID-fonter nøklet etter Unicode-kodepunkt, så hver CID bærer nøyaktig én ToUnicode-mapping. Det er det som stopper ekstraherte mellomrom fra å komme tilbake som U+00A0 (no-break space) og bindestreker som U+00AD (soft hyphen), både fra det levende dokumentet og fra den lagrede filen

Symptomet er dumt fordi det er usynlig. Et søkeindeks bommer på «two-x» fordi den lagrede strengen inneholder en myk bindestrek, en CSV-eksport deler annerledes, et diff-verktøy flagger linjer som ser identiske ut i alle visere. Ingenting i den renderede siden er feil; bare Unicode-en bak glyfene er det

Hvorfor kommer ekstraherte mellomrom tilbake som U+00A0?

Ekstraherte mellomrom blir til U+00A0 fordi ToUnicode CMap-en som PDFium genererer i FPDFText_LoadFont, er nøklet etter glyf, og én glyf kan nås fra to kodepunkter. I Arial serverer glyf 3 både U+0020 og U+00A0, og bindestrek-glyfen serverer både U+002D og U+00AD. Den genererte CMap-en mapper derfor samme CID to ganger, én gang gjennom en bfchar-oppføring og én gang gjennom en array-form bfrange, og den oppføringen leserens prioritetsregel begunstiger, blir den ekstraherte teksten

Hvorfor én Arial-glyf brøt PDF-tekstuttrekk i Delphi: U+0020 og U+00A0 når glyf 3 og U+002D og U+00AD når bindestrek-glyfen, så den genererte ToUnicode CMap-en mapper CID 0003 to ganger gjennom en bfchar-oppføring og en array-bfrange, og leserens prioritetsregel avgjør hvilket kodepunkt som ekstraheres
Lavest-vinner-prioritet holdt mellomrommene rene i årevis, helt til en oppstrøms bytte til sist-vinner gjorde at hvert AddText-mellomrom ekstraherte som NBSP og hver bindestrek som en myk bindestrek
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Lenge var denne motsetningen ufarlig, siden PDFium-leseren lot den laveste mappingen vinne. En oppstrøms endring byttet lesere til sist-vinner, og fra det bygget og frem ekstraherte hvert mellomrom skrevet med AddText som NBSP og hver bindestrek som en myk bindestrek. Legg merke til mønsteret i parene: 0x20/0xA0 og 0x2D/0xAD skiller seg bare i den høye biten, som er nøyaktig det du ville forvente fra en font hvis cmap sender Latin-1 tvillingene til samme omriss. Var uttrekk-koden din fin i går og feiler den nå på usynlige tegn, dump kodepunktene i stedet for å stole på feilsøkervisningen; det grunnleggende i å hente ut tekst er dekket i å ekstrahere tekst fra PDF-dokumenter med PDFium i Delphi

uses
  SysUtils, PDFium;

const
  // Space/U+00A0 og hyphen/U+00AD deler én Arial-glyf, og det gjør
  // gresk Omega (U+03A9) og Ohm-tegnet (U+2126) også
  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;                  // live, ulagret dokument
    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;              // etter en full lagring og gjeninnlesing
  finally
    Pdf.Free;
  end;

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

Hvorfor det å lappe CMap-en etter lagring ikke var nok

Å lappe den lagrede filen fikser bare den lagrede filen, og bare hvis lappen holder CMap-strukturen intakt byte for byte. Den første fiksen, RepairSubsetToUnicodeCMaps i uniten FPdfCompress, kjører etter hver ikke-inkrementelle TPdf.SaveAs og løser hver konflikterende CID: bfchar-oppføringen vinner, et par som skiller seg bare i den høye biten løses til det mindre base-latinske kodepunktet, og alt annet beholder sin første mapping

Den interessante delen er det negative resultatet. Å bygge den konflikterende CMap-en pent opp igjen, i enten start-kode- eller array-form, så ut som det åpenbare trekket, og PDFium avviste hver gjenoppbygd CMap kontant og falt tilbake til Identity. Det eneste output-et den native leseren aksepterte, var en like-lang in-place-erstatning av de konflikterende heks-verdiene, med blokklayout og CID-dekning urørt. Den andre lærdommen var ydmykere: notatet vårt på den tiden skyldte på at tilfellet i minnet kom av at det levende dokumentet ikke hadde noen ToUnicode-strøm i det hele tatt. Å kalle DLL-en direkte motsa det, siden det levende dokumentet bærer den samme tvetydige strømmen, noe som betydde at den egentlige fiksen måtte skje før PDFium i det hele tatt genererte CMap-en. Reparasjonsrutinen blir værende i biblioteket som et forsvar for PDF-er produsert av andre PDFium-baserte verktøy

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
      // Kun like-lange endringer; filer uten en reparerbar konflikt,
      // og kryssreferansestrøm- eller objektstrømfiler, kopieres som de er
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Nøkle fonten etter kodepunkt i stedet for etter glyf

Rotfiksen er å slutte å be PDFium generere CMap-en i det hele tatt. TPdf.LoadCachedFont overleverer nå systemfont-bytene til TPdf.LoadUnicodeKeyedCidFont, som leser fontens egen sfnt cmap-tabell, med preferanse for en format 12-subtabell og fallback til format 4. Kodepunkter kommer tilbake sortert og avdubbet, og CID k+1 tildeles det k-te kodepunktet, med CID 0 igjen som .notdef. En eksplisitt CIDToGIDMap sender hver CID til sin glyf, så U+0020 og U+00A0 får to forskjellige CID-er som tegner samme omriss, og ToUnicode CMap-en mapper hver CID til bare ett kodepunkt. Fonten lastes så gjennom FPDFText_LoadCidType2Font, det samme inngangspunktet bak glyfnivå-skriving i CID Type 2 font-innbygging med eksplisitte CID-til-GID-mapper

Kodepunkt-nøklet fiks i PDFium Component: LoadUnicodeKeyedCidFont leser fontens sfnt cmap, tildeler CID k+1 til hvert sortert kodepunkt med CID 0 som notdef, kobler en eksplisitt CIDToGIDMap slik at U+0020 og U+00A0 beholder forskjellige CID-er, og BuildUnicodeKeyedCidCMap gir hver CID nøyaktig ett kodepunkt
NBSP, myk bindestrek og Ohm-tegnet overlever deretter som seg selv under en hvilken som helst prioritetsregel, i det levende dokumentet og etter enhver lagring, så CMap-reparasjonen finner ingenting igjen å fikse
// Komprimert fra 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);       // én CID, ett kodepunkt
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Når FPDFText_SetText senere skriver en streng, lander oppslaget bakover på én enkelt CID per tegn, så NBSP, myk bindestrek og Ohm-tegnet overlever hver som seg selv under en hvilken som helst prioritetsregel, i minnet og etter enhver lagring. Fordi den lagrede filen bærer komponentens egen ToUnicode-strøm i stedet for en motor-generert, finner RepairSubsetToUnicodeCMaps ingenting å fikse i den

Hvorfor kan én bfrange-oppføring utslette en hel blokk?

En enkelt bfrange hvis CID-rekke krysser en xxFF-grense, får PDFium til å forkaste hele blokken den ligger i. ISO 32000-1 §9.10.3 lar bare den siste byten av destinasjonen variere innenfor et område, men CID-siden har sin egen felle: PDFiums HandleBeginBFRange avleder den høye CID-en som (low and $FFFFFF00) or (high and $FF). En rekke fra CID 00FE til 0101 leses derfor som 00FE til 0001, lav større enn høy, og hele blokken merkes som ugyldig. Feilen er stille: SetText lykkes, siden renderer perfekt, og uttrekket returnerer U+0000 for hvert tegn i den blokken

Den stille bfrange-fellen i PDF CMap-parsing: en CID-rekke fra 00FE til 0101 krysser en xxFF-grense, HandleBeginBFRange avleder den høye CID-en som 0001, lav større enn høy merker hele blokken som ugyldig, SetText og rendering lykkes fortsatt, og uttrekket returnerer U+0000 for hvert tegn i blokken
BuildUnicodeKeyedCidCMap unngår fellen ved å avslutte hver rekke før en lav byte på FF, holde blokker innenfor 100-oppføringsgrensen og skrive kodepunkter i supplementært plan som individuelle bfchar-oppføringer

BuildUnicodeKeyedCidCMap avslutter en rekke før enten kodepunktet eller CID-en når en lav byte på FF, holder hver blokk innenfor 100-oppføringsgrensen i CMap-grammatikken og skriver kodepunkter i supplementært plan som individuelle bfchar-oppføringer med UTF-16 surrogatpar-destinasjoner, siden å inkrementere et surrogatpar inne i et område ikke har noen definert betydning; surrogatsiden av den historien finnes i håndtering av emoji, CJK og surrogatpar i Delphi. En kun-bfchar-CMap ville omgå grenseproblemet fullstendig, til flere ganger størrelsen

Hva dekker ikke den kodepunkt-nøkle fonten?

Kodepunkt-nøkle stien dekker hver font som eksponerer en Unicode-cmap-subtabell, og faller tilbake til den gamle glyf-nøkle oppførselen for resten. Grensene verdt å kjenne før du støtter deg til den:

  • Symbolfonter med bare en (3,0) cmap, og enhver font CID-stien feiler å laste, går gjennom FPDFText_LoadFont som før, så en glyf delt av to kodepunkter kan fortsatt ekstraheres tvetydig der
  • Uten en format 12-subtabell er mappen begrenset til BMP-en, og oppføringsantallet er taksert til 65535 slik at hver CID får plass i to byte over null
  • Inkrementelle lagringer (saIncremental) hopper over RepairSubsetToUnicodeCMaps med vilje, fordi en inkrementell revisjon må forbli append-only; de kodepunkt-nøkle fontene gjør det irrelevant for tekst komponenten skriver selv
  • TrueType Collections trenger ekstra omsorg: GDI GetFontData returnerer hele .ttc-en, og FPDFText_LoadCidType2Font har ingen face-indeksparameter, så å be om NSimSun fra simsun.ttc pleide å bygge inn og rendre SimSun, face 0. Komponenten matcher nå familienavnet mot navnetabellen (nameID 1 og 16) og trekker ut den etterspurte face-en som en frittstående sfnt før cmap-en parses; feiler parsingen, går samlingsbytene rett gjennom og oppførselen går tilbake til face 0

Tekstskriving, fontinnbygging og uttrekk deler én sidemodell på tvers av Delphi, C++Builder og Lazarus, og hele API-et er beskrevet på produktsiden for PDFium Component for Delphi