Skip to content

With useOTL, a font counts as having characters it only uses internally, and can draw the wrong glyph #848

Open

Description

@gravitypdf-bot

With useOTL on, a font counts as having characters it doesn't have. TTFontFile gives every glyph its cmap doesn't map a made-up codepoint, starting at U+E000, so the glyph has a slot in the widths table. _charDefined() can't tell those slots from real characters, so text holding that codepoint can be drawn with an unrelated glyph.

Steps to reproduce (gravitypdf-v6 09b67ea, the head of #847)

$mpdf = new \Mpdf\Mpdf(['default_font' => 'freeserif', 'useSubstitutions' => true]);
$mpdf->SetCompression(false);
$mpdf->WriteHTML('AB');

FreeSerif's cmap has no Private Use characters at all. With backupSubsFont set to dejavusans:

FreeSerif's useOTL _charDefined(U+E000) Written Result
0xFF, the default true <418042> one of FreeSerif's glyphs, stored in slot 0x80
0 false <4100> then <42> glyph 0, the missing glyph

I read this from the page stream and haven't rasterised it, so I can't say which glyph it is.

How far it reaches

I parsed the 71 .ttf and .otf files in ttfonts/ at useOTL 0xFF, and compared five of them with and without it:

Font Private Use characters in the cmap Slots filled with OTL
Aegyptus 71 6,181
UnBatang 2,048 3,542
Jomolhari 1,537 2,794
FreeSerif 0 2,450
FreeSans 0 1,649

Past the Private Use Area. When U+E000–U+F8FF is full, the codepoints continue from U+2CEB0 (TTFontFile.php:1181, Fonts/FontSubsetter.php:671). The comment there calls U+2CEB0–U+2F7FF unassigned, but CJK Extensions F and I are assigned there now. A font that gets that far would claim real ideographs, and FontSubstitution::sipFontHas() would believe it. None of the 71 fonts does; Aegyptus comes closest, with 6,181 of the 6,400 slots filled. So this part is a risk for other fonts rather than something I've seen.

The answer also depends on where the widths come from

  • A shipped ranges file is written without OTL, so it holds only real characters.
  • A font's loaded widths, and its generated <family>.ranges.json in the font cache, come from the metrics, so they include the made-up codepoints.

So the scan can answer differently for one font before and after it loads.

What a fix needs

  • Separate the two questions. The made-up codepoints need width slots, because shaped glyphs look their widths up there, so they can't just be removed. The check for whether a font has an input character needs to skip them. That means recording which codepoints were assigned, and using that in the substitution scan and in UTF8toSubset() and the other code that draws text.
  • Generated ranges files: once they hold real characters only, rangesSource() can stop recording useOTL, and a cache format bump will replace the existing files.
  • U+2CEB0: decide whether the overflow stays there, and correct the comment. Moving it changes which glyphs get which slot, so it needs checking against the snapshots.
  • Tests:
    • U+E000 in FreeSerif at useOTL 0xFF is substituted, or drawn as the missing glyph, and not as one of its other glyphs.
    • Ligatures and alternates reached through GSUB still draw with the right widths.
    • A generated test font with more than 6,400 unmapped glyphs, so no third-party licence is needed, doesn't claim Extension F.
    • A captioned snapshot, rasterised and checked, that fails before the change.

Related

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions