Technical Article

Embedding CFF2 Variable Fonts in PDF with HotPDF for Delphi

HotPDF embeds OpenType variable fonts with CFF2 outlines by converting them, at the selected axis values, into a CID-keyed CFF1 font program that PDF readers already understand. Ordinary native text registered through RegisterUnicodeTTF uses the font's own cmap and hmtx tables, and the writer embeds the converted program under /FontFile3 with /Subtype /CIDFontType0C, keeping every CID equal to the original glyph ID

HotPDF pipeline for embedding a CFF2 variable font in Delphi as CID-keyed CFF1 under FontFile3, with the silent GDI substitution detour the registered-bytes path avoids
HotPDF freezes the variation at the chosen axis values and writes a CID-keyed CFF1 program where every CID equals the original glyph ID, so no layer translates between numberings

The obvious route is worth describing, because it fails without telling anyone. Parse and cache the CFF2 tables, then let the plain text path resolve the font through Windows GDI with a TrueType-only match: GDI has no idea what to do with CFF2 outlines and quietly substitutes ArialUnicodeMS. Anything downstream that reads the substitute gets the wrong outlines, the wrong widths and the wrong extraction for supplementary-plane characters. The PDF opens fine. It just is not set in the font you asked for, which is why the text path has to work from the registered bytes instead of a GDI lookup

Why can't a PDF embed a CFF2 font directly?

PDF has no slot for a CFF2 program. ISO 32000-2 §9.9 lists the embedded font program types a reader must handle: Type 1 under /FontFile, TrueType under /FontFile2, and under /FontFile3 a bare CFF program (Type1C or CIDFontType0C) or an OpenType wrapper around TrueType or CFF outlines. Every CFF flavour in that list means CFF version 1, the format readers have parsed for two decades

The three PDF embedded font slots, all limited to CFF version 1, and the missing CFF2 slot that makes HotPDF convert before embedding in Delphi
every CFF flavour PDF readers must handle is CFF version 1, so a CFF2 variable font has no slot until it is converted

CFF2 is a different format that happens to share a name. It drops the Name and String INDEX, drops the charset, takes glyph advances out of the CharStrings and leaves them to hmtx, and adds blend operators so a single outline can describe the whole design space. A CFF1 parser that meets a CFF2 table has no basis for guessing, and nothing in the PDF specification obliges it to try. Wrapping the CFF2 font in an OpenType stream and hoping for the best is exactly the kind of file that renders on one machine and fails on the next, so HotPDF does not do it

The conversion also has to be static. As the article on variable font static instancing explains, a PDF font is a fixed set of outlines with fixed metrics, and no reader is required to apply axis coordinates. HotPDF therefore resolves the variation first and only then writes CFF1

Writing native text in a CFF2 font

For application code, a CFF2 font behaves like any other registered Unicode font: select axis values, register the file, select the family by name, and draw. RegisterUnicodeTTF keeps a separate copy of the original name table, so SetFont matches the family name, full name, PostScript name or typographic family name and selects the frozen static font bytes, with no Windows installation or GDI lookup involved

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // Axis values apply to fonts registered after this call
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Family, full, PostScript or typographic family name from the name table
    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 come from the registered cmap and advances from the registered hmtx. A glyph whose advance is genuinely zero stays zero instead of falling back to a GDI measurement, and a UTF-16 surrogate pair produces exactly one advance, not two. BMP characters, supplementary characters and synthetic shaping carriers keep per-font Unicode mappings, so switching to another embedded font or starting a new page does not let that other font update this font's subset state or borrow its glyph IDs

Measurement follows the same bytes. THPDFPage.GetWideTextWidth and THPDFPage.GetWideCharAdvances read the metrics of the selected byte-backed font, so line breaking and right alignment agree with what gets drawn. One ordering detail catches people out: SetVariableFontAxis changes made after registration affect the next font you register, not the instance already frozen. To get a second weight, set the new value and register again

How does HPDFConvertCFF2ToCFF build the CID-keyed program?

HPDFConvertCFF2ToCFF produces a raw CID-keyed CFF1 program with a single Font DICT, in which CID equals the original glyph ID for every slot, including empty and unused ones. That identity is the whole design. The font name, the PDF /W widths, the CFF advances and the ToUnicode CMap all refer to the same numbering, so no layer has to translate between glyph IDs and CIDs, and nothing can drift out of step

On the PDF side the descendant is a CIDFontType0 font with Identity-H or Identity-V encoding and the program stream sits under /FontFile3 with /Subtype /CIDFontType0C. Because CID already equals GID, the descendant carries no /CIDToGIDMap, and the stream is excluded from the later stream-replacement hook used for registered TrueType fonts. Unlike the /Subtype /OpenType wrapper, the converted stream does not raise the PDF 1.6 version requirement

Widths needed a small trick. A CFF1 Type 2 CharString stores the advance as an optional first operand relative to nominalWidthX, and integer operands are limited to the signed 16-bit range. An OpenType advance is an unsigned 16-bit value, up to 65535. HotPDF sets nominalWidthX to 32768 and defaultWidthX to 0, then writes every advance explicitly as advance - 32768. A zero advance becomes -32768 and a 65535 advance becomes 32767, so the full uint16 range is representable without clamping a single glyph

How HotPDF maps unsigned 16-bit advances into signed CFF1 operands with nominalWidthX 32768, keeping every font advance in Delphi PDF output unclamped
nominalWidthX 32768 shifts the operand window so a zero advance stores as -32768 and 65535 stores as 32767 without clamping any glyph

Geometry gets the same care. The FontMatrix is [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0], so a 2048-unit font keeps its native coordinates. Relative moves and lines too large for one operand are split into representable steps, and cubic curves are subdivided in a way that preserves their shape. The original copyright and licence records, the original name table and the embedding flags travel along in the CFF Notice string

Calling the converter outside a document

The converter is a public function in the HPDFOpenTypeAdvanced unit, written in Pascal, with no HarfBuzz dependency and no system font installation step. That makes it usable on its own, for example to pre-convert a font at build time or to inspect what the PDF writer will embed

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;

Variable input goes through geometry-only static instancing at the given coordinates, covering both outlines and advances. A static CFF2 font accepts only an empty selection; passing axis values to it is treated as an error rather than ignored. The result is a bare CFF1 program, not an sfnt container, so do not hand it to code expecting an OpenType file

Failure is an empty TBytes, not an exception, for the expected cases: input that is not CFF2, an axis selection the instancer does not accept, non-finite values such as NaN, and fonts whose OS/2 fsType sets restricted-licence embedding (0x0002) or bitmap-only embedding (0x0200). Always test the length. Inside EndDoc the writer does that for you and raises "StoreFont: CFF2 outline conversion failed", which is the right call: a blank font program in a shipped PDF is far worse than a visible error in your log

Budgets and cancellation for server workloads

The optional third parameter, THPDFVariableFontControl, bounds memory and lets you cancel a long conversion. Without it the converter uses its defaults: a 128 MiB limit on font input and output, a 1 GiB cumulative working budget, and the CFF1 limit of 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;  // propagates out of HPDFConvertCFF2ToCFF
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // maximum output bytes
    Int64(256) * 1024 * 1024,   // cumulative working budget
    CheckCancel);
  try
    // nil selection: default instance of a variable font, or a static CFF2 font
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

Budget overruns and cancellation are reported as exceptions, deliberately distinct from the empty-result path. An empty array means the font cannot be converted; an exception means this attempt was stopped. Keep those two apart in your error handling, because retrying an unconvertible font with a bigger budget will not help

How much does an embedded CFF2 font weigh?

The main thing to plan for is file size: HotPDF embeds the complete converted glyph set for CFF2 fonts, whether EnableFontSubsetting is on or off. A font with several thousand glyphs adds all of them to every document that uses it, even if the page shows one headline. For a brochure that is acceptable. For a report server writing thousands of small PDFs it is a real cost you should measure before committing

The related size features follow the same rule. Converted CFF2 fonts are embedded whole, so neither the compact font subsets enabled by CompressDocument nor the persistent font subset cache applies to them. TrueType fonts in the same document are still subset and cached as usual. The one benefit of keeping every glyph is that fonts whose licence forbids subsetting are embedded in a form that respects it

The resource limits are finite conversion limits, and CFF1 itself caps what a single CharString can hold. A glyph whose converted CharString exceeds 65535 bytes, or a font that blows through the working budget, fails visibly instead of being written with an empty outline. That is the intended trade-off: a loud failure on an exotic font beats a document with silently missing glyphs

Native CFF2 text and the portable CFF1 converter ship with the HotPDF Delphi PDF component for Delphi and C++Builder, alongside the variable font instancing, colour glyph and font subsetting features covered elsewhere on this blog