Technisch artikel

ToUnicode-keying: NBSP en soft hyphen in Delphi-extractie

Een zoekopdracht naar "two-x" in een pas gegenereerde PDF kan nul treffers opleveren terwijl de pagina duidelijk die vijf tekens toont. Dump de geëxtraheerde string als codepunten en de reden staat er: het koppelteken kwam terug als U+00AD SOFT HYPHEN, en de spatie erachter als U+00A0 NO-BREAK SPACE. Op het scherm zien ze er identiek uit. Het verschil zit in de ToUnicode-CMap, de tabel die een reader raadpleegt om character codes terug naar Unicode te vertalen, en het komt door één fontglyph die door twee codepunten wordt gedeeld

Gewone stringhulpmiddelen maken de mismatch erger. Een CSV-export breekt de regel anders op, een diff-tool vlagt twee regels die in elke viewer hetzelfde lezen, en een spellingcontrole behandelt "two-x" met een soft hyphen als onbekend woord. Unicode-normalisatie redt u maar half: NFKC vouwt U+00A0 in een gewone spatie omdat NBSP een compatibiliteitsdecompositie heeft, maar U+00AD heeft helemaal geen decompositie, dus de soft hyphen overleeft elke normalisatievorm

Waarom komen geëxtraheerde spaties terug als U+00A0?

Geëxtraheerde spaties worden U+00A0 wanneer de ToUnicode-CMap op glyph is gesleuteld en één glyph vanaf twee codepunten bereikbaar is. In Arial dient glyph 3 zowel U+0020 als U+00A0, en de koppelteken-glyph dient zowel U+002D als U+00AD. Een CMap gebouwd vanaf de glyphkant moet iets zeggen over CID 0003, en de generator van PDFium in FPDFText_LoadFont zegt uiteindelijk twee dingen: een bfchar-invoer die hem naar U+0020 mapt, en een array-vorm bfrange die dezelfde CID naar U+00A0 mapt

Hoe één Arial-glyph PDF-tekstextractie in Delphi op de verkeerde been brengt: U+0020 en U+00A0 bereiken glyph 3 en U+002D en U+00AD bereiken de koppelteken-glyph, dus de gegenereerde ToUnicode-CMap mapt CID 0003 dubbel via een bfchar-invoer en een array-bfrange, en de precedentieregel van de reader bepaalt welk codepunt eruit komt
Onder laagste-wint-precedentie blijft de spatie gewoon; onder laatste-wint, de regel die de PDFium-reader toepast, extraheert een op glyph gesleutelde CMap elke spatie als NBSP en elk koppelteken als soft hyphen
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

ISO 32000-1 §9.10.3 beschrijft hoe bfchar en bfrange codes naar Unicode mappen, maar zegt niet welke invoer wint wanneer twee ervan dezelfde CID claimen. Readers kiezen een eigen precedentieregel. Een reader die de laagste of eerste mapping houdt extraheert een gewone spatie; de reader van PDFium houdt de laatste, en omdat de array-range na het bfchar-blok volgt, extraheert elke spatie die via een op glyph gesleutelde CMap is geschreven als NBSP en elk koppelteken als soft hyphen. Dezelfde tekst kan daardoor in twee viewers van hetzelfde bestand anders extraheren

De paren volgen een patroon. 0x20/0xA0 en 0x2D/0xAD verschillen alleen in de hoge bit, en dat produceert een font wanneer zijn cmap de Latijnse-1-lookalikes naar de omtrek stuurt die al voor het ASCII-teken in gebruik was. Ook Griekse hoofdletter Omega (U+03A9) en het Ohm-teken (U+2126) delen een glyph in Arial. Als extractiecode die eerst werkte nu faalt op tekens die u niet kunt zien, print dan de codepunten in plaats van de stringweergave van de debugger te vertrouwen; de basis van tekst uit een pagina lezen staat in tekst uit PDF-documenten extraheren met PDFium in Delphi

Hoe PDFium Component systeemfonts op codepunt sleutelt

PDFium Component vraagt de engine nooit om die CMap te genereren voor systeemfonts die een Unicode-cmap meedragen. Wanneer TPdf.AddText een font nodig heeft, leest de component de eigen sfnt-cmap-tabel van het font, bij voorkeur een subtable formaat 12 met terugval op formaat 4. Codepunten komen gesorteerd en ontdubbeld terug, en CID k+1 wordt aan het k-ste codepunt toegekend, met CID 0 als .notdef. Een expliciete CIDToGIDMap stuurt elke CID naar zijn glyph. U+0020 en U+00A0 krijgen zo twee verschillende CIDs die dezelfde omtrek tekenen, en de ToUnicode-CMap die de component schrijft mapt elke CID naar precies één codepunt

CID-toewijzing op codepunt in PDFium Component: de component leest de sfnt-cmap van het font, kent CID k+1 toe aan elk gesorteerd codepunt met CID 0 als notdef, bedraadt een expliciete CIDToGIDMap zodat U+0020 en U+00A0 verschillende CIDs houden, en schrijft een ToUnicode-CMap die elke CID precies één codepunt geeft
NBSP, soft hyphen en het Ohm-teken overleven daarna als zichzelf onder welke precedentieregel dan ook, in het live document en na elke save, dus de CMap-reparatie vindt niets meer te repareren

Het font wordt geladen via FPDFText_LoadCidType2Font, hetzelfde toegangspunt achter het schrijven op glyphniveau in CID Type 2-fonts insluiten met expliciete CID-naar-GID-maps. Wanneer FPDFText_SetText later een string schrijft, landt de omgekeerde opzoeking op één CID per teken. Niets daaraan hangt af van de precedentieregel van de reader, want er is geen tweede mapping om tussen te kiezen. Het opgeslagen bestand draagt ook de eigen ToUnicode-stream van de component, dus extractie na herladen ziet dezelfde een-op-een-tabel die het live document gebruikte

De prijs is een grotere CIDToGIDMap: twee bytes per codepunt dat het font dekt. Stel dat een font ongeveer 3.000 codepunten mapt. De map komt dan op ruwweg 6 KB, bij elke font aan de engine meegegeven. Die ruil is elke keer waard. Een paar kilobytes koopt extractie die hetzelfde leest in PDFium, in viewers die de eerste mapping houden, en in welke indexer uw klant volgend jaar ook draait

De rondtrip hieronder schrijft een string die elk lastelijk paar bevat, extraheert die uit het niet-opgeslagen document, slaat op, herlaadt en extraheert opnieuw. Beide strings moeten exact met de invoer overeenkomen; de helper CodePoints is er alleen om een mismatch leesbaar te maken:

uses
  SysUtils, PDFium;

const
  // Spatie/U+00A0 en koppelteken/U+00AD delen één Arial-glyph, net als
  // Griekse Omega (U+03A9) en het Ohm-teken (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;                  // live, niet-opgeslagen document
    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;              // na een volledige save en herlaad
  finally
    Pdf.Free;
  end;

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

Houd de NBSP en de soft hyphen in de voorbeeldstring, ook als uw documenten ze zelden bevatten. Een test die alleen ASCII schrijft slaagt ook tegen een op glyph gesleutelde CMap, want niets erin vraagt de reader ooit om tussen twee mappings te kiezen

Waarom kan één bfrange-invoer een heel blok uitwissen?

Een enkele bfrange waarvan de CID-run een xxFF-grens overschrijdt laat PDFium het hele blok waarin hij staat weggooien. ISO 32000-1 §9.10.3 laat binnen een range alleen de laatste byte van de bestemming variëren, en de CID-kant heeft een passende valkuil in de parser van PDFium: die leidt de hoge CID af als (low and $FFFFFF00) or (high and $FF). Een run van CID 00FE tot 0101 wordt gelezen als 00FE tot 0001, laag groter dan hoog, en het hele blok wordt als ongeldig gemarkeerd. SetText slaagt, de pagina rendert correct, en extractie geeft U+0000 terug voor elk teken in dat blok

De bfrange-valkuil bij het parsen van PDF-CMaps: een CID-run van 00FE tot 0101 overschrijdt een xxFF-grens, de parser van PDFium leidt de hoge CID af als 0001, laag groter dan hoog markeert het hele blok ongeldig, SetText en rendering slagen nog steeds, en extractie geeft U+0000 terug voor elk teken in het blok
De CMap-schrijver van PDFium Component ontloopt de valkuil door elke run te eindigen vóór een lage byte van FF, blokken binnen de limiet van 100 invoeren te houden en codepunten uit het supplementaire vlak als individuele bfchar-invoeren te schrijven

Een op codepunt gesleuteld font is precies waar dit bijt. CIDs zijn dicht en opeenvolgend, dus elk font met meer dan 255 codepunten levert CID-runs op die 00FF, 01FF enzovoorts zouden overschrijden als de schrijver opeenvolgende invoeren gewoon zou samenvoegen. De CMap die PDFium Component schrijft eindigt een run voordat het codepunt of de CID een lage byte van FF bereikt, en hij splitst de invoeren in blokken van ten hoogste 100, de limiet die de CMap-grammatica stelt aan elke sectie beginbfrange en beginbfchar

Codepunten boven U+FFFF krijgen individuele bfchar-invoeren waarvan de bestemmingen UTF-16-surrogaatparen zijn, want een surrogaatpaar binnen een range ophogen heeft geen gedefinieerde betekenis. Het surrogaatverhaal staat in emoji, CJK en surrogaatparen in Delphi. Een CMap met alleen bfchar zou het grensprobleem helemaal omzeilen, tegen een veelvoud van de grootte, en daarom blijven ranges de hoofdvorm en dragen de splitregels de last

ToUnicode-conflicten in bestanden van andere tools repareren

RepairSubsetToUnicodeCMaps in de unit FPdfCompress lost het op glyph gesleutelde conflict op in bestanden die de component niet zelf schreef. TPdf.SaveAs voert het uit bij elke volledige, niet-incrementele save, en u kunt het rechtstreeks op elke stream aanroepen. Voor elke CID die twee bestemmingen draagt wint de bfchar-invoer. Zonder bfchar voor de CID lost een paar dat alleen in de hoge bit verschilt op naar het kleinste base-Latin-codepunt. Elke ander conflict houdt zijn eerste mapping

De reparatie bewerkt de conflicterende hex-waarden op hun plek, met vervangingen van gelijke lengte. Bloklay-out, CID-dekking en elke andere byte van de stream blijven zoals ze waren, dus de CMap blijft in de vorm die de reader van PDFium al accepteerde. Een netjes herbouwde CMap zou er in een hexdump netter uitzien. Hij zou ook de structuur veranderen van een stream die een strenge reader al accepteerde, en dat is een gok zonder keer wanneer er maar een handjevol bestemmingswaarden fout zijn

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
      // Alleen bewerkingen van gelijke lengte; bestanden zonder repareerbaar conflict,
      // en cross-reference-stream- of object-stream-bestanden, worden gekopieerd zoals ze zijn
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end.

De routine herbouwt een klassieke cross-reference-tabel, dus documenten die cross-reference-streams, hybride referenties of object-streams gebruiken gaan byte voor byte door. Incrementele saves (saIncremental) slaan de reparatie met opzet over, omdat een incrementele revisie append-only moet blijven. Voor tekst die de component zelf schrijft doen beide gevallen er niet toe: zijn op codepunt gesleutelde fonts produceren nooit een conflict dat de reparatie zou vinden

Waar het op codepunt gesleutelde pad geldt

Het codepunt-gesleutelde pad dekt elk font dat een Unicode-cmap-subtable blootgeeft en valt voor de rest terug op het oude op glyph gesleutelde gedrag. De grenzen die goed om te kennen zijn voordat u erop vertrouwt:

  • Symbolfonts die alleen een (3,0)-cmap meedragen, en elk font dat het CID-pad niet kan laden, gaan via FPDFText_LoadFont, waar een glyph die door twee codepunten wordt gedeeld onderworpen is aan de precedentieregel van de reader
  • Met alleen een subtable formaat 4 dekt de map het Basic Multilingual Plane; een subtable formaat 12 op platform (3,10) of het Unicode-platform voegt codepunten uit het supplementaire vlak toe
  • Glyph 0 en surrogaatcodepunten worden weggelaten, en het aantal invoeren is gekapt op 65535 zodat elke CID in twee bytes boven nul past
  • TrueType Collections vragen extra zorg: GDI GetFontData geeft de hele .ttc terug, en FPDFText_LoadCidType2Font heeft geen face-indexparameter, dus de collection erblind doorgeven zou face 0 insluiten, SimSun, terwijl u NSimSun uit simsun.ttc vroeg. De component matcht de familienaam tegen de name table van het font (nameID 1 en 16) en levert de gevraagde face aan als zelfstandige sfnt; een collection die hij niet kan parsen gaat heel door en gebruikt face 0
  • Als er geen systeemfont van de gevraagde naam leesbaar is, geeft AddText de fontnaam door aan FPDFPageObj_NewTextObj, die hem opzoekt onder de standaardfonts van PDFium

Vouw onzichtbare tekens in de index samen, houd ze in de tekst

Zodra extractie de echte codepunten teruggeeft, komt een andere vraag op: wat moet een zoekindex doen met een echte NBSP of soft hyphen die de auteur met opzet typte? Vouw ze samen in de indexeringslaag en laat TPdf.Text met rust. De geëxtraheerde string is de registratie van wat het document zegt; een index is een verliesgevend beeld gebouwd om te matchen, en de samenvouwregels horen daar thuis

De helper hieronder is een functie die u aan uw eigen code toevoegt. Hij mapt NBSP naar een spatie en gooit de soft hyphen weg, die volgens de Unicode Standard alleen een plek markeert waar een regel mag breken. Hij laat U+2126 bewust onaangeroerd. NFC-normalisatie zou het Ohm-teken in Omega veranderen, omdat U+2126 een canonieke singleton-decompositie naar U+03A9 heeft, en of zoeken in een datasheet die twee moet matchen is een productbeslissing in plaats van een extractievraag:

uses
  SysUtils, Classes, PDFium;

// Uw eigen helper voor een zoekindex; geen deel van PDFium Component
function FoldForIndex(const S: string): string;
begin
  Result := StringReplace(S, #$00A0, ' ', [rfReplaceAll]);
  Result := StringReplace(Result, #$00AD, '', [rfReplaceAll]);
end;

procedure IndexDocument(const FileName: string; Lines: TStrings);
var
  Pdf: TPdf;
  P: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for P := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := P;
      Lines.Add(FoldForIndex(Pdf.Text));   // exacte tekst blijft in Pdf.Text
    end;
  finally
    Pdf.Free;
  end;
end;

Draai IndexDocument op het bestand codepoints.pdf uit het eerste voorbeeld en de geïndexeerde regel luidt "two-x yz ΩΩ", elf tekens met een Ohm-teken als laatste, terwijl Pdf.Text nog steeds alle twaalf oorspronkelijke tekens teruggeeft, NBSP en soft hyphen inbegrepen

Op codepunt gesleutelde fonts, de CMap-reparatie en tekstextractie zitten op hetzelfde paginamodel in Delphi, C++Builder en Lazarus; de font- en tekst-API wordt volledig behandeld op de productpagina van PDFium Component for Delphi