Teknisk artikel

ToUnicode-fix: NBSP og soft hyphen i PDF-udtræk i Delphi

PDFium Component til Delphi indlejrer de systemfonts, som TPdf.AddText bruger, som CID-fonts nøglet efter Unicode-kodepunkt, så hver CID bærer præcis én ToUnicode-mapping. Det er dét, der forhindrer udtrukne mellemrum i at komme tilbage som U+00A0 (hårdt mellemrum) og bindestreger som U+00AD (blødt bindestregstegn), både fra det levende dokument og fra den gemte fil

Symptomet er ubehageligt, fordi det er usynligt. Et søgeindeks missér "two-x", fordi den gemte streng indeholder et blødt bindestregstegn, en CSV-eksport deler anderledes, et diff-værktøj flagger linjer, der ser identiske ud i alle viewere. Intet i den renderede side er forkert; kun Unicode bag glyfferne er det

Hvorfor kommer udtrukne mellemrum tilbage som U+00A0?

Udtrukne mellemrum bliver til U+00A0, fordi ToUnicode CMap'en, som PDFium genererer i FPDFText_LoadFont, er nøglet efter glyf, og én glyf kan nås fra to kodepunkter. I Arial betjener glyf 3 både U+0020 og U+00A0, og bindestregs-glyffen betjener både U+002D og U+00AD. Den genererede CMap mapper derfor samme CID to gange, én gang gennem en bfchar-entry og én gang gennem en array-formet bfrange, og dén entry, som læserens prioritetsregel begunstiger, bliver den udtrukne tekst

Hvorfor én Arial-glyf brød PDF-tekstudtræk i Delphi: U+0020 og U+00A0 når glyf 3, og U+002D og U+00AD når bindestregs-glyffen, så den genererede ToUnicode CMap mapper CID 0003 to gange gennem en bfchar-entry og en array-bfrange, og læserens prioritetsregel beslutter, hvilket kodepunkt der udtrækkes
Lowest-wins-prioritet holdt mellemrum rene i årevis, indtil et upstream-skift til last-wins fik hvert AddText-mellemrum til at udtrække som NBSP og hver bindestreg som et blødt bindestregstegn
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Længe var denne modstrid harmløs, da PDFium-læseren lod den laveste mapping vinde. En upstream-ændring skiftede læseren til last-wins, og fra den build af udtrak hvert mellemrum skrevet med AddText som NBSP og hver bindestreg som et blødt bindestregstegn. Bemærk mønsteret i parrene: 0x20/0xA0 og 0x2D/0xAD adskiller sig kun i den høje bit, hvilket er præcis, hvad man ville forvente fra en font, hvis cmap sender Latin-1 look-alikes til samme outline. Var din udtrækskode fin i går og fejler nu på usynlige tegn, så dump kodepunkterne frem for at stole på debugger-viewen; grundlæggende om at trække tekst ud er dækket i udtræk af tekst fra PDF-dokumenter med PDFium i Delphi

uses
  SysUtils, PDFium;

const
  // Space/U+00A0 og bindestreg/U+00AD deler én Arial-glyf, og det gør
  // græsk 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;                  // levende, ugemt 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 fuld gemning og genindlæsning
  finally
    Pdf.Free;
  end;

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

Hvorfor det ikke var nok at lappe CMap'en efter gemning

At lappe den gemte fil retter kun den gemte fil, og kun hvis lappen holder CMap-strukturen byte-for-byte intakt. Det første fix, RepairSubsetToUnicodeCMaps i uniten FPdfCompress, kører efter hver ikke-inkrementel TPdf.SaveAs og resolver hver konfliktende CID: bfchar-entryen vinder, et par, der kun adskiller sig i den høje bit, resolser til det mindre base-Latin-kodepunkt, og alt andet beholder sin første mapping

Den interessante del er det negative resultat. At genbygge den konfliktende CMap rent, i enten start-kode- eller array-form, lignede det oplagte træk, og PDFium afviste hver genbygget CMap kategorisk og faldt tilbage til Identity. Det eneste output, den native læser accepterede, var en ligelang erstatning på stedet af de konfliktende hex-værdier, med bloklayout og CID-dækning urørt. Anden lektie var mere ydmygende: vores note på daværende tidspunkt bebrejdede in-memory-tilfældet det levende dokument slet ikke at have en ToUnicode-stream. At kalde DLL'en direkte modbeviste det, da det levende dokument bærer samme tvetydige stream, hvilket betød, at det rigtige fix skulle ske, før PDFium nogensinde genererede CMap'en. Reparationsrutinen forbliver i biblioteket som et værn mod PDF'er produceret af andre PDFium-baserede værktøjer

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 ligelange redigeringer; filer uden reparerbar konflikt,
      // og cross-reference-stream- eller object-stream-filer, kopieres som de er
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

At nøgle fonten efter kodepunkt i stedet for efter glyf

Rodfixet er at holde op med at bede PDFium om overhovedet at generere CMap'en. TPdf.LoadCachedFont giver nu systemfontens bytes til TPdf.LoadUnicodeKeyedCidFont, som læser fontens egen sfnt cmap-tabel, foretrækker en format 12-undertabel og falder tilbage til format 4. Kodepunkter kommer tilbage sorteret og deduplikerede, og CID k+1 tildeles det k'te kodepunkt, med CID 0 efterladt som .notdef. En eksplicit CIDToGIDMap sender hver CID til sin glyf, så U+0020 og U+00A0 får to forskellige CID'er, der tegner samme outline, og ToUnicode CMap'en mapper hver CID til ét kodepunkt. Fonten indlæses derefter gennem FPDFText_LoadCidType2Font, samme indgangspunkt bag glyf-niveau-skrivning i CID Type 2 font embedding med eksplicitte CID-til-GID-maps

Kodepunkts-nøglede fix i PDFium Component: LoadUnicodeKeyedCidFont læser fontens sfnt cmap, tildeler CID k+1 til hvert sorteret kodepunkt med CID 0 som notdef, kobler en eksplicit CIDToGIDMap, så U+0020 og U+00A0 beholder forskellige CID'er, og BuildUnicodeKeyedCidCMap giver hver CID præcis ét kodepunkt
NBSP, blødt bindestregstegn og Ohm-tegnet overlever derefter som sig selv under begge prioritetsregler, i det levende dokument og efter enhver gemning, så CMap-reparationen ikke finder noget tilbage at rette
// Komprimeret 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, ét kodepunkt
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Når FPDFText_SetText senere skriver en streng, lander det omvendte opslag på én enkelt CID pr. tegn, så NBSP, blødt bindestregstegn og Ohm-tegnet hver overlever som sig selv under begge prioritetsregler, i hukommelsen og efter enhver gemning. Fordi den gemte fil bærer komponentens egen ToUnicode-stream frem for en motorgenereret, finder RepairSubsetToUnicodeCMaps intet at rette i den

Hvorfor kan én bfrange-entry udviske en hel blok?

En enkelt bfrange, hvis CID-run krydser en xxFF-grænse, får PDFium til at kassere hele blokken, den sidder i. ISO 32000-1 §9.10.3 tillader kun, at destinationens sidste byte varierer inden for et interval, men CID-siden har sin egen fælde: PDFiums HandleBeginBFRange afleder den høje CID som (low and $FFFFFF00) or (high and $FF). Et run fra CID 00FE til 0101 læses derfor som 00FE til 0001, lav større end høj, og hele blokken markeres ugyldig. Fejlen er lydløs: SetText lykkes, siden renderer perfekt, og udtræk returnerer U+0000 for hvert tegn i den blok

Den lydløse bfrange-fælde i PDF CMap-parsing: et CID-run fra 00FE til 0101 krydser en xxFF-grænse, HandleBeginBFRange afleder den høje CID som 0001, lav større end høj markerer hele blokken ugyldig, SetText og rendering lykkes stadig, og udtræk returnerer U+0000 for hvert tegn i blokken
BuildUnicodeKeyedCidCMap undgår fælden ved at afslutte hvert run før en lav byte på FF, holde blokke inden for 100-entry-grænsen og skrive supplementary-plane kodepunkter som enkelte bfchar-entries

BuildUnicodeKeyedCidCMap afslutter et run, før enten kodepunktet eller CID'en når en lav byte på FF, holder hver blok inden for 100-entry-grænsen i CMap-grammatikken og skriver supplementary-plane kodepunkter som enkelte bfchar-entries med UTF-16 surrogate par-destinationer, da det at inkrementere et surrogate par inden for et interval har ingen defineret betydning; surrogate-siden af den historie står i emoji, CJK og surrogate pair-håndtering i Delphi. En CMap med kun bfchar ville omgå grænseproblemet helt, til flere gange størrelsen

Hvad dækker den kodepunkts-nøglede font ikke?

Den kodepunkts-nøglede sti dækker enhver font, der eksponerer en Unicode cmap-undertabel, og falder tilbage til den gamle glyf-nøglede adfærd for resten. Grænserne, der er værd at kende, før du støtter dig til den:

  • Symbolfonts med kun en (3,0) cmap, og enhver font, CID-stien fejler at indlæse, går gennem FPDFText_LoadFont som før, så en glyf delt af to kodepunkter stadig kan udtrække tvetydigt dér
  • Uden en format 12-undertabel er mappen begrænset til BMP, og entry-antallet cappes ved 65535, så hver CID er to bytes over nul
  • Inkrementelle gemninger (saIncremental) springer RepairSubsetToUnicodeCMaps over af design, for en inkrementel revision skal forblive append-only; de kodepunkts-nøglede fonts gør det irrelevant for tekst, komponenten selv skriver
  • TrueType Collections kræver ekstra omhu: GDI GetFontData returnerer hele .ttc'en, og FPDFText_LoadCidType2Font har ingen face index-parameter, så en forespørgsel efter NSimSun fra simsun.ttc indlejrede og renderede tidligere SimSun, face 0. Komponenten matcher nu familienavnet mod name-tabellen (nameID 1 og 16) og udtrækker den efterspurgte face som en selvstændig sfnt, før cmap'en parses; fejler parsingen, passeres samlingsbytes, og adfærden vender tilbage til face 0

Tekstskrivning, font-indlejring og udtræk deler én sidemodel på tværs af Delphi, C++Builder og Lazarus, og det fulde API er beskrevet på PDFium Component for Delphi produktsiden