Teknisk artikel

ToUnicode-fix: NBSP och mjuk avstavning i PDF-extraktion

PDFium Component for Delphi bäddar in systemtypsnitten som TPdf.AddText använder som CID-typsnitt nycklade efter Unicode-kodpunkt, så varje CID bär exakt en ToUnicode-mappning. Det är vad som hindrar extraherade mellanslag från att komma tillbaka som U+00A0 (no-break space) och bindestreck som U+00AD (mjuk avstavning), både från det levande dokumentet och från den sparade filen

Symptomet är elakt för att det är osynligt. Ett sökindex missar "two-x" för att den lagrade strängen innehåller en mjuk avstavning, en CSV-export delar annorlunda, ett diff-verktyg flaggar rader som ser identiska ut i varje visare. Ingenting på den renderade sidan är fel; bara Unicode bakom glyferna är det

Varför kommer extraherade mellanslag tillbaka som U+00A0?

Extraherade mellanslag blir U+00A0 för att ToUnicode-CMap:en som PDFium genererar i FPDFText_LoadFont är nycklad efter glyf, och en glyf kan nås från två kodpunkter. I Arial tjänar glyf 3 både U+0020 och U+00A0, och bindestrecksglyfen tjänar både U+002D och U+00AD. Den genererade CMap:en mappar alltså samma CID två gånger, en gång via en bfchar-post och en gång via en arrayformad bfrange, och den post som läsarens prioriteringsregel föredrar blir den extraherade texten

Varför en enda Arial-glyf krossade PDF-textextraktion i Delphi: U+0020 och U+00A0 når glyf 3 och U+002D och U+00AD når bindestrecksglyfen, så den genererade ToUnicode-CMap:en mappar CID 0003 två gånger via en bfchar-post och en array-bfrange, och läsarens prioriteringsregel avgör vilken kodpunkt som extraheras
Lägsta-vinner-prioriteringen höll mellanslagen vanliga i åratal, tills en uppströms växling till sista-vinner gjorde att varje AddText-mellanslag extraherades som NBSP och varje bindestreck som mjuk avstavning
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Länge var denna motsägelse ofarlig, eftersom PDFium-läsaren lät den lägsta mappningen vinna. En uppströms ändring växlade läsaren till sista-vinner, och från det bygget extraherades varje mellanslag skrivet med AddText som NBSP och varje bindestreck som mjuk avstavning. Notera mönstret i paren: 0x20/0xA0 och 0x2D/0xAD skiljer sig bara i högsta biten, vilket är precis vad man väntar sig av ett typsnitt vars cmap skickar Latin-1-tvillingar till samma kontur. Om din extraktionskod var fin i går och nu faller på osynliga tecken, dumpa kodpunkterna i stället för att lita på felsökarens vy; grunderna i att plocka ut text tas upp i att extrahera text från PDF-dokument med PDFium i Delphi

uses
  SysUtils, PDFium;

const
  // Mellanslag/U+00A0 och bindestreck/U+00AD delar en Arial-glyf, liksom
  // grekiska Omega (U+03A9) och Ohm-tecknet (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;                  // levande, osparat 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;              // efter en full sparning och omloadning
  finally
    Pdf.Free;
  end;

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

Varför det inte räckte att laga CMap:en efter sparningen

Att laga den sparade filen fixar bara den sparade filen, och bara om lagningen håller CMap-strukturen intakt byte för byte. Den första fixen, RepairSubsetToUnicodeCMaps i enheten FPdfCompress, körs efter varje icke-inkrementell TPdf.SaveAs och löser varje motstridig CID: bfchar-posten vinner, ett par som skiljer sig bara i högsta biten löses till den mindre base-Latin-kodpunkten, och allt annat behåller sin första mappning

Det intressanta är det negativa resultatet. Att bygga om den motstridiga CMap:en rent, i antingen startkod- eller arrayform, såg ut som det uppenbara greppet, och PDFium avvisade varje ombyggd CMap rakt av och föll tillbaka på Identity. Den enda utdata som den nativa läsaren accepterade var en lika lång ersättning på plats av de motstridiga hexvärdena, med blocklayout och CID-täckning orörda. Den andra lärdomen var ödmjukare: vår notering då skyllde fallet i minnet på att det levande dokumentet inte hade någon ToUnicode-ström alls. Att anropa DLL:en direkt motbevisade det, eftersom det levande dokumentet bär samma tvetydiga ström, vilket betydde att den riktiga fixen måste ske innan PDFium överhuvudtaget genererade CMap:en. Reparationsrutinen stannar i biblioteket som ett försvar för PDF:er producerade av andra PDFium-baserade verktyg

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
      // Endast lika långa redigeringar; filer utan en reparabel konflikt,
      // samt filer med korsreferensström eller objektström, kopieras som de är
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Nyckla typsnittet efter kodpunkt i stället för efter glyf

Rotfixen är att sluta be PDFium generera CMap:en alls. TPdf.LoadCachedFont lämnar nu systemtypsnittets byte till TPdf.LoadUnicodeKeyedCidFont, som läser typsnittets egen sfnt-tabell cmap med föredrag av en format 12-subtabell och tillbakafall på format 4. Kodpunkterna kommer tillbaka sorterade och avduplicerade, och CID k+1 tilldelas den k:e kodpunkten, med CID 0 lämnad som .notdef. En explicit CIDToGIDMap skickar varje CID till sin glyf, så U+0020 och U+00A0 får två olika CIDs som ritar samma kontur, och ToUnicode-CMap:en mappar varje CID till en enda kodpunkt. Typsnittet laddas sedan genom FPDFText_LoadCidType2Font, samma ingångspunkt som ligger bakom skrivning på glyfnivå i CID Type 2-inbäddning av typsnitt med explicita CID-till-GID-mappar

Kodpunktnycklade fixen i PDFium Component: LoadUnicodeKeyedCidFont läser typsnittets sfnt cmap, tilldelar CID k+1 till varje sorterad kodpunkt med CID 0 som notdef, kopplar en explicit CIDToGIDMap så att U+0020 och U+00A0 behåller olika CIDs, och BuildUnicodeKeyedCidCMap ger varje CID exakt en kodpunkt
NBSP, mjuk avstavning och Ohm-tecknet överlever då som sig själva under vilken prioriteringsregel som helst, i det levande dokumentet och efter varje sparning, så CMap-reparationen hittar inget kvar att laga
// Komprimerat från 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);       // en CID, en kodpunkt
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

När FPDFText_SetText senare skriver en sträng landar uppslaget baklänges på en enda CID per tecken, så NBSP, mjuk avstavning och Ohm-tecknet överlever var och en som sig själva under vilken prioriteringsregel som helst, i minnet och efter varje sparning. Eftersom den sparade filen bär komponentens egen ToUnicode-ström i stället för en motorgenererad hittar RepairSubsetToUnicodeCMaps inget att laga i den

Varför kan en enda bfrange-post utplåna ett helt block?

En enda bfrange vars CID-rad korsar en xxFF-gräns får PDFium att kassera hela blocket den sitter i. ISO 32000-1 §9.10.3 låter bara sista byten i destinationen variera inom ett intervall, men CID-sidan har en egen fälla: PDFiums HandleBeginBFRange härleder den höga CID:n som (low and $FFFFFF00) or (high and $FF). En rad från CID 00FE till 0101 läses alltså som 00FE till 0001, lägre större än högre, och hela blocket markeras ogiltigt. Felet är tyst: SetText lyckas, sidan renderar perfekt, och extraktionen returnerar U+0000 för varje tecken i det blocket

Den tysta bfrange-fällan i PDF CMap-parsning: en CID-rad från 00FE till 0101 korsar en xxFF-gräns, HandleBeginBFRange härleder den höga CID:n som 0001, lägre större än högre markerar hela blocket ogiltigt, SetText och rendering lyckas fortfarande, och extraktionen returnerar U+0000 för varje tecken i blocket
BuildUnicodeKeyedCidCMap undviker fällan genom att avsluta varje rad innan en låg byte på FF, håller blocken inom gränsen på 100 poster och skriver kodpunkter på kompletmentariska plan som enskilda bfchar-poster

BuildUnicodeKeyedCidCMap avslutar en rad innan antingen kodpunkten eller CID:n når en låg byte på FF, håller varje block inom CMap-grammatikens gräns på 100 poster, och skriver kodpunkter på kompletmentariska plan som enskilda bfchar-poster med UTF-16-surrogatpar-destinationer, eftersom att öka ett surrogatpar inuti ett intervall ingen definierad betydelse har; surrogatsidan av den historien finns i hantering av emoji, CJK och surrogatpar i Delphi. En CMap med bara bfchar skulle kringgå gränsproblemet helt, till flera gånger storleken

Vad täcker inte det kodpunktnycklade typsnittet?

Den kodpunktnycklade vägen täcker varje typsnitt som exponerar en Unicode-cmap-subtabell, och faller tillbaka på det gamla glyfnycklade beteendet för resten. Gränserna värda att känna till innan du litar på den:

  • Symboltypsnitt med bara en (3,0)-cmap, och varje typsnitt CID-vägen misslyckas ladda, går genom FPDFText_LoadFont som tidigare, så en glyf delad av två kodpunkter kan fortfarande extraheras tvetydigt där
  • Utan en format 12-subtabell begränsas mappen till BMP, och antalet poster takas vid 65535 så att varje CID ryms i två byte över noll
  • Inkrementella sparningar (saIncremental) hoppar över RepairSubsetToUnicodeCMaps med flit, för en inkrementell revision måste förbli enbart-tilläggande; de kodpunktnycklade typsnitten gör det ovidkommande för text komponenten själv skriver
  • TrueType Collections kräver extra omsorg: GDI:s GetFontData returnerar hela .ttc-filen, och FPDFText_LoadCidType2Font saknar parameter för face-index, så en förfrågan om NSimSun ur simsun.ttc brukade bädda in och rendera SimSun, face 0. Komponenten matchar nu familjenamnet mot namntabellen (nameID 1 och 16) och extraherar den begärda face:n som ett fristående sfnt innan cmap:en parsas; misslyckas parsningen passerar samlingsbytena och beteendet återgår till face 0

Textskrivning, typsnittsinbäddning och extraktion delar en sidmodell över Delphi, C++Builder och Lazarus, och hela API:et beskrivs på produktsidan för PDFium Component for Delphi