Technisch artikel

CFF2-variable fonts in PDF insluiten met HotPDF

HotPDF sluit OpenType-variable fonts met CFF2-outlines in door ze bij de gekozen as-waarden om te zetten naar een CID-keyed CFF1-fontprogramma dat PDF-readers al begrijpen. Gewone native tekst die via RegisterUnicodeTTF wordt geregistreerd, gebruikt de eigen cmap- en hmtx-tabellen van het font, en de writer sluit het omgezette programma in onder /FontFile3 met /Subtype /CIDFontType0C, waarbij elke CID gelijk blijft aan de originele glyph-ID

HotPDF-pipeline voor het insluiten van een CFF2-variable font in Delphi als CID-keyed CFF1 onder FontFile3, met de stille GDI-substitutie-omweg die de registered-bytes-route vermijdt
HotPDF bevriest de variatie op de gekozen as-waarden en schrijft een CID-keyed CFF1-programma waarin elke CID gelijk is aan de originele glyph-ID, zodat geen enkele laag tussen nummeringen vertaalt

De voor de hand liggende route is het beschrijven waard, want hij faalt zonder iemand iets te vertellen. Parseer de CFF2-tabellen en cache ze, en laat daarna het platte-tekstpad het font via Windows GDI oplossen met een TrueType-only match: GDI weet geen raad met CFF2-outlines en substitueert stilletjes ArialUnicodeMS. Alles stroomafwaarts dat de substituut leest, krijgt de verkeerde outlines, de verkeerde breedtes en de verkeerde extractie voor tekens buiten het BMP. De PDF opent prima. Er staat alleen niet het font in waar je om vroeg, en daarom moet het tekstpad vanuit de geregistreerde bytes werken in plaats van via een GDI-lookup

Waarom kan een PDF een CFF2-font niet rechtstreeks insluiten?

PDF heeft geen plek voor een CFF2-programma. ISO 32000-2 §9.9 somt de ingesloten fontprogrammatypes op die een reader moet aankunnen: Type 1 onder /FontFile, TrueType onder /FontFile2, en onder /FontFile3 een kaal CFF-programma (Type1C of CIDFontType0C) of een OpenType-wrapper rond TrueType- of CFF-outlines. Elke CFF-variant in die lijst betekent CFF versie 1, het formaat dat readers al twee decennia parsen

De drie PDF-slots voor ingesloten fonts, allemaal beperkt tot CFF versie 1, en het ontbrekende CFF2-slot dat HotPDF laat converteren vóór het insluiten in Delphi
elke CFF-variant die PDF-readers moeten aankunnen is CFF versie 1, dus een CFF2-variable font heeft geen slot totdat het is omgezet

CFF2 is een ander formaat dat toevallig dezelfde naam draagt. Het schrapt de Name- en String-INDEX, schrapt de charset, haalt de glyph-breedtes uit de CharStrings en laat die aan hmtx over, en voegt blend-operators toe zodat één outline de hele design space kan beschrijven. Een CFF1-parser die een CFF2-tabel tegenkomt heeft geen enkele grond om te gokken, en niets in de PDF-specificatie verplicht hem tot een poging. Het CFF2-font in een OpenType-stream stoppen en op het beste hopen is precies het soort bestand dat op de ene machine rendert en op de volgende faalt, dus HotPDF doet dat niet

De conversie moet ook statisch zijn. Zoals het artikel over statische instancing van variable fonts uitlegt, is een PDF-font een vaste set outlines met vaste metrics, en geen enkele reader is verplicht om as-coördinaten toe te passen. HotPDF lost daarom eerst de variatie op en schrijft pas daarna CFF1

Native tekst schrijven in een CFF2-font

Voor applicatiecode gedraagt een CFF2-font zich als elk ander geregistreerd Unicode-font: kies as-waarden, registreer het bestand, selecteer de familie op naam en teken. RegisterUnicodeTTF houdt een aparte kopie van de originele namentabel bij, dus SetFont matcht de familienaam, volledige naam, PostScript-naam of typografische familienaam en selecteert de bevroren statische fontbytes, zonder Windows-installatie of GDI-lookup

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // As-waarden gelden voor fonts die na deze aanroep worden geregistreerd
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Familie-, volledige, PostScript- of typografische familienaam uit de namentabel
    Pdf.CurrentPage.SetFont('Acme Serif VF', [], 12, DEFAULT_CHARSET);
    Pdf.CurrentPage.UnicodeTextOut(72, 100, 0, 'Variable weight 650, CFF2 outlines');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Glyph-IDs komen uit de geregistreerde cmap en breedtes uit de geregistreerde hmtx. Een glyph wiens breedte echt nul is, blijft nul in plaats van terug te vallen op een GDI-meting, en een UTF-16-surrogaanpaar levert exact één breedte op, niet twee. BMP-tekens, supplementaire tekens en synthetische shaping-carriers houden Unicode-mappings per font, dus naar een ander ingesloten font overschakelen of een nieuwe pagina beginnen geeft dat andere font geen kans om de subsetstatus van dit font bij te werken of zijn glyph-IDs te lenen

Ook het meten volgt dezelfde bytes. THPDFPage.GetWideTextWidth en THPDFPage.GetWideCharAdvances lezen de metrics van het geselecteerde font uit zijn bytes, dus regelafbreken en rechts uitlijnen kloppen met wat er getekend wordt. Eén volgorde-detail trips mensen: SetVariableFontAxis-wijzigingen na registratie gelden voor het volgende font dat je registreert, niet voor de al bevroren instantie. Wil je een tweede gewicht, zet dan de nieuwe waarde en registreer opnieuw

Hoe bouwt HPDFConvertCFF2ToCFF het CID-keyed programma?

HPDFConvertCFF2ToCFF levert een kaal CID-keyed CFF1-programma op met één Font DICT, waarin CID voor elke sleuf gelijk is aan de originele glyph-ID, ook voor lege en ongebruikte. Die gelijkheid is het hele ontwerp. De fontnaam, de PDF-/W-breedtes, de CFF-breedtes en de ToUnicode CMap verwijzen allemaal naar dezelfde nummering, dus geen enkele laag hoeft tussen glyph-IDs en CIDs te vertalen, en niets kan uit de pas lopen

Aan de PDF-kant is de afstammeling een CIDFontType0-font met Identity-H- of Identity-V-encoding en zit de programmastream onder /FontFile3 met /Subtype /CIDFontType0C. Omdat CID al gelijk is aan GID, draagt de afstammeling geen /CIDToGIDMap, en de stream valt buiten de latere stream-vervanghook die voor geregistreerde TrueType-fonts dient. In tegenstelling tot de /Subtype /OpenType-wrapper verhoogt de omgezette stream de PDF 1.6-versie-eis niet

De breedtes vroegen om een klein trucje. Een CFF1 Type 2 CharString slaat de breedte op als optionele eerste operand relatief aan nominalWidthX, en integer-operands zijn beperkt tot het signed 16-bit bereik. Een OpenType-breedte is een unsigned 16-bit waarde, tot 65535. HotPDF zet nominalWidthX op 32768 en defaultWidthX op 0 en schrijft dan elke breedte expliciet als advance - 32768. Een breedte van nul wordt -32768 en een breedte van 65535 wordt 32767, dus de volledige uint16-range is representeerbaar zonder ook maar één glyph te klemmen

Hoe HotPDF unsigned 16-bit breedtes in signed CFF1-operands landt met nominalWidthX 32768, zodat elke fontbreedte in Delphi-PDF-output ongeklemd blijft
nominalWidthX 32768 schuift het operandvenster op, zodat een breedte van nul als -32768 wordt opgeslagen en 65535 als 32767, zonder ook maar één glyph te klemmen

De geometrie krijgt dezelfde zorg. De FontMatrix is [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0], dus een font van 2048 units houdt zijn native coördinaten. Relatieve moves en lijnen die te groot zijn voor één operand worden gesplitst in representeerbare stappen, en kubische curven worden gesubdivideerd op een manier die hun vorm bewaart. De originele copyright- en licentierecords, de originele namentabel en de embedding-vlaggen reizen mee in de CFF Notice-string

De converter aanroepen buiten een document

De converter is een publieke functie in de unit HPDFOpenTypeAdvanced, geschreven in Pascal, zonder HarfBuzz-afhankelijkheid en zonder systeemfont-installatiestap. Daardoor is hij los te gebruiken, bijvoorbeeld om een font vóór de build om te zetten of om te bekijken wat de PDF-writer gaat insluiten

uses
  System.SysUtils, System.IOUtils, HPDFOpenTypeAdvanced;

procedure ConvertWeight650;
var
  Source, Cff: TBytes;
  Axes: THPDFVariableFontAxisValueArray;
begin
  Source := TFile.ReadAllBytes('C:\Fonts\AcmeSerifVF.otf');
  SetLength(Axes, 1);
  Axes[0].Tag := 'wght';
  Axes[0].Value := 650;
  Cff := HPDFConvertCFF2ToCFF(Source, Axes);
  if Length(Cff) = 0 then
    raise Exception.Create('Not convertible: not CFF2, embedding denied or bad axes');
  TFile.WriteAllBytes('AcmeSerif-650.cff', Cff);
end;

Variabele input gaat door geometry-only statische instancing op de opgegeven coördinaten, voor zowel outlines als breedtes. Een statisch CFF2-font accepteert alleen een lege selectie; hem as-waarden geven wordt als fout behandeld in plaats van genegeerd. Het resultaat is een kaal CFF1-programma, geen sfnt-container, dus geef het niet aan code die een OpenType-bestand verwacht

Voor de verwachte gevallen krijg je een lege TBytes terug in plaats van een exception: input die geen CFF2 is, een as-selectie die de instancer niet accepteert, niet-finitieve waarden zoals NaN, en fonts waarvan de OS/2-fsType restricted-license embedding (0x0002) of bitmap-only embedding (0x0200) aanzet. Test altijd de lengte. Binnen EndDoc doet de writer dat voor je en gooit "StoreFont: CFF2 outline conversion failed", wat de juiste keus is: een leeg fontprogramma in een uitgegeven PDF is veel erger dan een zichtbare fout in je log

Budgetten en annuleren voor serverworkloads

De optionele derde parameter, THPDFVariableFontControl, begrenst het geheugen en laat je een lange conversie annuleren. Zonder die gebruikt de converter zijn defaults: een limiet van 128 MiB op font-input en -output, een cumulatief werkbudget van 1 GiB en de CFF1-limiet van 65535 bytes per CharString

type
  TFontJob = class
  private
    FCancelled: Boolean;
    procedure CheckCancel;
  public
    function Convert(const Source: TBytes): TBytes;
    property Cancelled: Boolean read FCancelled write FCancelled;
  end;

procedure TFontJob.CheckCancel;
begin
  if FCancelled then
    Abort;  // propageert uit HPDFConvertCFF2ToCFF
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // maximale outputbytes
    Int64(256) * 1024 * 1024,   // cumulatief werkbudget
    CheckCancel);
  try
    // nil-selectie: default-instantie van een variable font, of een statisch CFF2-font
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

Budgetoverschrijdingen en annulering worden als exceptions gemeld, bewust gescheiden van het lege-resultaatpad. Een lege array betekent dat het font niet te converteren is; een exception betekent dat deze poging is stopgezet. Houd die twee uit elkaar in je foutafhandeling, want een niet-converteerbaar font met een groter budget opnieuw proberen helpt niet

Hoeveel weegt een ingesloten CFF2-font?

Het belangrijkste om op voorhand te plannen is de bestandsgrootte: HotPDF sluit de volledige omgezette glyphset in voor CFF2-fonts, of EnableFontSubsetting nu aan of uit staat. Een font met enkele duizenden glyphs voegt ze allemaal toe aan elk document dat het gebruikt, ook als de pagina één kop laat zien. Voor een brochure is dat acceptabel. Voor een rapportserver die duizenden kleine PDFs schrijft is het een echte kost die je beter meet voordat je je ergens toe verplicht

De verwante groottefuncties volgen dezelfde regel. Omgezette CFF2-fonts worden in hun geheel ingesloten, dus zowel de compacte fontsubsets die CompressDocument aanzet als de persistente fontsubsetcache gelden niet voor ze. TrueType-fonts in hetzelfde document worden nog steeds gesubset en gecached zoals gebruikelijk. Het enige voordeel van elke glyph vasthouden is dat fonts waarvan de licentie subsetting verbiedt, in een vorm worden ingesloten die die respecteert

De resourcelimieten zijn conversielimieten, en CFF1 zelf kapwt wat één CharString kan bevatten. Een glyph waarvan de omgezette CharString boven 65535 bytes uitkomt, of een font dat door het werkbudget heen schiet, faalt zichtbaar in plaats van met een lege outline te worden weggeschreven. Dat is de bedoelde afweging: een luidruchtige mislukking op een exotisch font is beter dan een document met stilletjes ontbrekende glyphs

Native CFF2-tekst en de portable CFF1-converter worden meegeleverd met de HotPDF Delphi PDF-component voor Delphi en C++Builder, naast de variable font instancing, color glyph- en fontsubsetting-functies die elders op deze blog worden behandeld