Skip to content

FontTrueType: no Unicode fallback for subset fonts with embedded SFNT font file but no ToUnicode CMap #430

Description

@dan98765

Problem

When a FontTrueType font (simple TrueType, Subtype: TrueType) is subset-embedded without a /ToUnicode CMap, page.extract_text raises HexaPDF::Error:

No Unicode mapping for code point 50 in font AAAAAC+Calibri

This is common in PDFs exported from Microsoft Word and Apple Pages, which subset-embed Calibri using this embedding strategy, often omitting the ToUnicode CMap. The PDF renders correctly in all viewers, but text extraction fails entirely.

Reproduction

Export any Word (.docx) or Pages document that uses Calibri body text to PDF on Windows (default Word PDF export settings) or macOS Pages. Then:

require "hexapdf"

doc = HexaPDF::Document.open("calibri_word_export.pdf")
doc.pages.each { |page| puts page.extract_text }
# => HexaPDF::Error: No Unicode mapping for code point 50 in font AAAAAC+Calibri

The stack trace goes through FontSimple#to_utf8 at lib/hexapdf/type/font_simple.rb.

Root cause

FontSimple#to_utf8 (lib/hexapdf/type/font_simple.rb) has the following fallback chain:

def to_utf8(code)
  to_unicode_cmap&.to_unicode(code) || (encoding.unicode(code) rescue nil) ||
    missing_unicode_mapping(code)
end

For this class of PDF:

  1. to_unicode_cmap — absent (omitted by the exporter)
  2. encoding.unicode(code) — returns nil for the affected code points (the encoding can't map them)
  3. missing_unicode_mapping — raises (or invokes the user config callback)

The underlying reason encoding.unicode fails: FontTrueType#font_wrapper (lib/hexapdf/type/font_true_type.rb) explicitly excludes subset fonts from parsing the embedded font file:

def font_wrapper
  if (tmp = super)
    tmp
  elsif (font_file = self.font_file) && self[:BaseFont].to_s !~ /\A[A-Z]{6}\+/
    # ^^^ this excludes AAAAAC+Calibri and all subset-embedded fonts
    font = HexaPDF::Font::TrueType::Font.new(StringIO.new(font_file.stream))
    @font_wrapper = HexaPDF::Font::TrueTypeWrapper.new(document, font, subset: true)
  end
end

The AAAAAC+ prefix marks the font as a subset, so font_wrapper is never set and the embedded font file is never read for Unicode extraction.

Suggested fix

In FontTrueType: implement encoding_from_font (or a to_utf8 override) that reads the SFNT cmap table from the embedded subset font file even when the full TrueTypeWrapper is not appropriate. The embedded subset's cmap table is valid for all glyphs present in the subset — reading it for text extraction is safe, even though using it for new text embedding would not be.

HexaPDF::Font::TrueType::Font already supports parsing the cmap table; the change is narrow: allow that parsing in the read/extraction path for subset fonts, separately from the write/embedding path.

Prior discussion

Issue #349 (Mar 2025) discussed missing Unicode mappings in the context of OpenType CFF fonts. The Calibri case is slightly different — the font is embedded as a simple FontTrueType, not a composite Type0 font — but the same underlying gap applies: the embedded font file contains a usable cmap table that HexaPDF doesn't consult for subsets.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions