From 0d33ac981b5ade4887a2a8267dcf401b7b2e64c2 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Tue, 22 Sep 2026 06:20:03 +1000 Subject: [PATCH] Draw a COLR version 0 font in colour, and a colour font's plain glyphs from its outlines (#76) GlyphOutline decodes a glyf glyph to a PDF path in font units: simple glyphs with their repeated flags and short and long deltas, implied on-curve points, each quadratic as the cubic that draws it, and composites moved, scaled or transformed. It draws what fontTools draws for Noto Sans' outlines and composites and for the fixture's contour of off-curve points alone, reads a glyph whole and checks each field fits before reading it, and keeps each path it draws. loca is read by one helper, which FontSubsetter uses as well. ColrV0Source fills each layer of a COLR version 0 glyph in its colour from CPAL's first palette, inside q and Q, so a layer in the colour of the text - palette index 0xFFFF - is filled in whatever colour the text is. A layer less than opaque is filled through a graphics state, which the subset's own resource dictionary names beside its images, and which _putextgstates() writes when a glyph registers it after the rest were written. A version 1 font is drawn from the version 0 records beside its paints, ahead of any bitmaps it has. Every source opens the font through ColorFontFile, which decodes its outlines once. A glyph is drawn by the first source that has it: the colour format, then OutlineSource, so a glyph with no colour of its own is its outline in the colour of the text. A colour font with outlines that is not drawn in colour - under PDF/A, say - is drawn from them, and only a font of bitmaps alone is left blank. A font whose ligatures take U+FE0F in, as Twemoji's do, keeps its selectors through GSUB and loses the ones no ligature took, where Noto's leaves them out and they are taken out first. The cache records whether a font has outlines and which kind of sequences it forms, and its format goes to 7. Co-Authored-By: Claude Opus 5 (1M context) --- src/Fonts/BlobReader.php | 8 + src/Fonts/Color/BitmapSource.php | 9 +- src/Fonts/Color/CbdtSource.php | 19 +- src/Fonts/Color/ColorFontFile.php | 78 ++++ src/Fonts/Color/ColorFormats.php | 46 +- src/Fonts/Color/ColrV0Source.php | 144 ++++++ src/Fonts/Color/GlyphResources.php | 13 +- src/Fonts/Color/OutlineSource.php | 34 ++ src/Fonts/Color/SbixSource.php | 19 +- src/Fonts/FontSubsetter.php | 49 +- src/Fonts/FontSubstitution.php | 42 +- src/Fonts/GlyphOperator.php | 27 ++ src/Fonts/GlyphOutline.php | 420 ++++++++++++++++++ src/Fonts/MetricsGenerator.php | 16 +- src/Fonts/Table/GsubOutputs.php | 57 ++- src/Fonts/Table/Loca.php | 64 +++ src/Mpdf.php | 14 +- src/Otl.php | 27 +- src/TTFontFile.php | 62 ++- src/Unicode/Emoji.php | 5 + src/Writer/Type3FontWriter.php | 153 +++++-- tests/Mpdf/ColorFontTest.php | 77 +++- tests/Mpdf/Fonts/Color/CbdtSourceTest.php | 4 +- tests/Mpdf/Fonts/Color/ColrV0SourceTest.php | 185 ++++++++ tests/Mpdf/Fonts/Color/RecordingResources.php | 8 + tests/Mpdf/Fonts/Color/SbixSourceTest.php | 4 +- tests/Mpdf/Fonts/Color/SyntheticFonts.php | 29 ++ tests/Mpdf/Fonts/ColorFormatsTest.php | 57 ++- tests/Mpdf/Fonts/GlyphOutlineTest.php | 136 ++++++ tests/Mpdf/PresentationSelectorTest.php | 72 +++ .../ColorEmojiColrV0SnapshotTest.php | 51 +++ tests/data/glyphoutline/build.py | 68 +++ tests/data/glyphoutline/paths.json | 46 ++ tests/data/snapshots/color-emoji-colrv0.pdf | Bin 0 -> 30328 bytes tests/data/ttf/color/TestEmoji-FE0F.ttf | Bin 0 -> 3396 bytes tests/data/ttf/color/build.py | 15 +- 36 files changed, 1867 insertions(+), 191 deletions(-) create mode 100644 src/Fonts/Color/ColorFontFile.php create mode 100644 src/Fonts/Color/ColrV0Source.php create mode 100644 src/Fonts/Color/OutlineSource.php create mode 100644 src/Fonts/GlyphOutline.php create mode 100644 src/Fonts/Table/Loca.php create mode 100644 tests/Mpdf/Fonts/Color/ColrV0SourceTest.php create mode 100644 tests/Mpdf/Fonts/GlyphOutlineTest.php create mode 100644 tests/Mpdf/PresentationSelectorTest.php create mode 100644 tests/Snapshots/ColorEmojiColrV0SnapshotTest.php create mode 100644 tests/data/glyphoutline/build.py create mode 100644 tests/data/glyphoutline/paths.json create mode 100644 tests/data/snapshots/color-emoji-colrv0.pdf create mode 100644 tests/data/ttf/color/TestEmoji-FE0F.ttf diff --git a/src/Fonts/BlobReader.php b/src/Fonts/BlobReader.php index e7e66343b..24268f812 100644 --- a/src/Fonts/BlobReader.php +++ b/src/Fonts/BlobReader.php @@ -26,6 +26,14 @@ public function __construct($bytes) $this->bytes = $bytes; } + /** + * @return int How many bytes there are to read + */ + public function length() + { + return strlen($this->bytes); + } + public function read($length) { $data = substr($this->bytes, $this->pos, $length); diff --git a/src/Fonts/Color/BitmapSource.php b/src/Fonts/Color/BitmapSource.php index d1cf8330f..41b35f80d 100644 --- a/src/Fonts/Color/BitmapSource.php +++ b/src/Fonts/Color/BitmapSource.php @@ -31,13 +31,12 @@ abstract class BitmapSource implements ColorGlyphSource protected $scale; /** - * @param FileReader $reader The font file - * @param LoggerInterface $logger Told of a glyph the format has a bitmap for that cannot be drawn + * @param ColorFontFile $file The font */ - public function __construct(FileReader $reader, LoggerInterface $logger) + public function __construct(ColorFontFile $file) { - $this->reader = $reader; - $this->logger = $logger; + $this->reader = $file->reader; + $this->logger = $file->logger; } /** diff --git a/src/Fonts/Color/CbdtSource.php b/src/Fonts/Color/CbdtSource.php index a9b998f44..aafce09ac 100644 --- a/src/Fonts/Color/CbdtSource.php +++ b/src/Fonts/Color/CbdtSource.php @@ -2,10 +2,7 @@ namespace Mpdf\Fonts\Color; -use Mpdf\Fonts\FileReader; use Mpdf\Log\Context as LogContext; -use Mpdf\TTFontFile; -use Psr\Log\LoggerInterface; /** * Colour glyphs as PNG bitmaps, in CBDT with CBLC to index them: Noto Color Emoji's format. @@ -39,16 +36,14 @@ class CbdtSource extends BitmapSource private $subtables = []; /** - * @param TTFontFile $font The font, its table directory read - * @param FileReader $reader The font file - * @param int $unitsPerEm - * @param LoggerInterface $logger Told of a bitmap in an image format mPDF does not draw + * @param ColorFontFile $file The font, whose logger is told of a bitmap in an image format mPDF does + * not draw */ - public function __construct(TTFontFile $font, FileReader $reader, $unitsPerEm, LoggerInterface $logger) + public function __construct(ColorFontFile $file) { - parent::__construct($reader, $logger); - $cblc = $font->getTablePosition('CBLC')[0]; - list($cbdt, $cbdtLength) = $font->getTablePosition('CBDT'); + parent::__construct($file); + $cblc = $file->table('CBLC')[0]; + list($cbdt, $cbdtLength) = $file->table('CBDT'); $this->cbdtEnd = $cbdt + $cbdtLength; $header = $this->reader->fieldsAt($cblc + 4, 4, 'N'); @@ -74,7 +69,7 @@ public function __construct(TTFontFile $font, FileReader $reader, $unitsPerEm, L } list($ppem, $array, $subtableCount) = $strike; - $this->scale = $unitsPerEm / $ppem; + $this->scale = $file->unitsPerEm / $ppem; for ($i = 0; $i < $subtableCount; $i++) { // The array ends with the file, if not before diff --git a/src/Fonts/Color/ColorFontFile.php b/src/Fonts/Color/ColorFontFile.php new file mode 100644 index 000000000..d369e5a2e --- /dev/null +++ b/src/Fonts/Color/ColorFontFile.php @@ -0,0 +1,78 @@ +font = $font; + $this->reader = $reader; + $this->unitsPerEm = $unitsPerEm; + $this->logger = $logger; + } + + /** + * @param string $tag + * + * @return int[] [where the table starts, its length], each 0 where the font has none + */ + public function table($tag) + { + return $this->font->getTablePosition($tag); + } + + /** + * @return GlyphOutline + */ + public function outline() + { + if ($this->outline === null) { + $this->outline = new GlyphOutline($this->font, $this->reader, $this->logger); + } + + return $this->outline; + } +} diff --git a/src/Fonts/Color/ColorFormats.php b/src/Fonts/Color/ColorFormats.php index f23672af3..2c22ee0ac 100644 --- a/src/Fonts/Color/ColorFormats.php +++ b/src/Fonts/Color/ColorFormats.php @@ -2,10 +2,7 @@ namespace Mpdf\Fonts\Color; -use Mpdf\Fonts\FileReader; use Mpdf\Mpdf; -use Mpdf\TTFontFile; -use Psr\Log\LoggerInterface; /** * The colour formats mPDF draws, and which of a font's it draws the font in. @@ -18,6 +15,7 @@ class ColorFormats * carries several is drawn in */ const SOURCES = [ + 'COLRv0' => 'Mpdf\Fonts\Color\ColrV0Source', 'CBDT' => 'Mpdf\Fonts\Color\CbdtSource', 'sbix' => 'Mpdf\Fonts\Color\SbixSource', ]; @@ -66,8 +64,28 @@ public static function drawn(array $font, Mpdf $mpdf) } /** - * Whether a font draws nothing in a document: its glyphs exist only in a colour format, and the - * document may not draw colour. Its characters are left blank, unless a backup font has them. + * What draws a font's glyphs in a document, each glyph by the first that has it: the format it is + * drawn in, where the document may draw colour, then its outlines, where it has any + * + * @param array $font The font, as Mpdf::$fonts holds it + * @param Mpdf $mpdf The document + * + * @return string[] The ColorGlyphSource classes + */ + public static function sources(array $font, Mpdf $mpdf) + { + $format = self::drawn($font, $mpdf); + $sources = $format === '' ? [] : [self::SOURCES[$format]]; + if (!empty($font['hasOutlines'])) { + $sources[] = 'Mpdf\Fonts\Color\OutlineSource'; + } + + return $sources; + } + + /** + * Whether a font written as Type3 draws nothing in a document: nothing draws its glyphs - see + * sources() - so its characters are left blank, unless a backup font has them. * * @param array $font The font, as Mpdf::$fonts holds it * @param Mpdf $mpdf The document @@ -76,7 +94,7 @@ public static function drawn(array $font, Mpdf $mpdf) */ public static function blank(array $font, Mpdf $mpdf) { - return !empty($font['colorFormats']) && self::drawable($font['colorFormats']) && !self::inColor($mpdf); + return !empty($font['colorFormats']) && self::drawable($font['colorFormats']) && !self::sources($font, $mpdf); } /** @@ -93,20 +111,4 @@ public static function choose(array $fontFormats, $color) return $formats ? reset($formats) : ''; } - - /** - * @param string $format One of the keys of SOURCES - * @param TTFontFile $font The font, its table directory read - * @param FileReader $reader The font file - * @param int $unitsPerEm - * @param LoggerInterface $logger Told of a glyph the format has but mPDF cannot draw - * - * @return ColorGlyphSource - */ - public static function source($format, TTFontFile $font, FileReader $reader, $unitsPerEm, LoggerInterface $logger) - { - $class = self::SOURCES[$format]; - - return new $class($font, $reader, $unitsPerEm, $logger); - } } diff --git a/src/Fonts/Color/ColrV0Source.php b/src/Fonts/Color/ColrV0Source.php new file mode 100644 index 000000000..438dacaab --- /dev/null +++ b/src/Fonts/Color/ColrV0Source.php @@ -0,0 +1,144 @@ + [its first layer record, how many layers] + */ + private $baseGlyphs = []; + + /** + * @var int Where the layer records start + */ + private $layers = 0; + + /** + * @var int How many layer records there are + */ + private $layerCount = 0; + + /** + * @var int[][] The first palette, each colour as [red, green, blue, alpha] from 0 to 255 + */ + private $palette = []; + + /** + * @param ColorFontFile $file The font + */ + public function __construct(ColorFontFile $file) + { + $this->reader = $file->reader; + $this->outline = $file->outline(); + + // numBaseGlyphRecords, baseGlyphRecordsOffset, layerRecordsOffset, numLayerRecords + $colr = $file->table('COLR')[0]; + $header = $this->reader->fieldsAt($colr + 2, 12, 'nbase/Nrecords/Nlayers/ncount'); + if ($header === null) { + return; + } + + list($baseCount, $records, $layers, $this->layerCount) = $header; + $this->layers = $colr + $layers; + + // Each glyph id, its first layer and how many + $values = $this->reader->fieldsAt($colr + $records, $baseCount * 6, 'n*'); + for ($i = 0; $values !== null && $i < 3 * $baseCount; $i += 3) { + $this->baseGlyphs[$values[$i]] = [$values[$i + 1], $values[$i + 2]]; + } + + $this->palette = $this->firstPalette($file->table('CPAL')[0]); + } + + /** + * @inheritdoc + */ + public function draw($glyph, GlyphResources $resources) + { + if (!isset($this->baseGlyphs[$glyph])) { + return null; + } + + list($first, $count) = $this->baseGlyphs[$glyph]; + $records = $first + $count > $this->layerCount ? null : $this->reader->fieldsAt($this->layers + $first * 4, $count * 4, 'n*'); + if ($records === null) { + return null; + } + + $content = ''; + foreach (array_chunk($records, 2) as $layer) { + list($layerGlyph, $index) = $layer; + $path = $this->outline->path($layerGlyph); + if ($path === '') { + continue; + } + + if ($index === self::FOREGROUND || !isset($this->palette[$index])) { + $content .= $path . "f\n"; + continue; + } + + list($red, $green, $blue, $alpha) = $this->palette[$index]; + $content .= sprintf("q %s%.3F %.3F %.3F rg\n%sf\nQ\n", $alpha < 255 ? $resources->alpha($alpha / 255) . ' ' : '', $red / 255, $green / 255, $blue / 255, $path); + } + + return $content === '' ? null : $content; + } + + /** + * @param int $cpal Where CPAL starts + * + * @return int[][] The first palette's colours, or none where CPAL has no palette or its first runs + * past the colour records + */ + private function firstPalette($cpal) + { + // numPaletteEntries, numPalettes, numColorRecords, colorRecordsArrayOffset, colorRecordIndices[0] + $header = $this->reader->fieldsAt($cpal + 2, 12, 'nentries/npalettes/nrecords/Ncolors/nfirst'); + if ($header === null || $header[1] === 0 || $header[4] + $header[0] > $header[2]) { + return []; + } + + list($entries, , , $colors, $first) = $header; + + // Each colour is stored blue, green, red, alpha + $bgra = $this->reader->fieldsAt($cpal + $colors + $first * 4, $entries * 4, 'C*'); + $palette = []; + for ($i = 0; $bgra !== null && $i < 4 * $entries; $i += 4) { + $palette[] = [$bgra[$i + 2], $bgra[$i + 1], $bgra[$i], $bgra[$i + 3]]; + } + + return $palette; + } +} diff --git a/src/Fonts/Color/GlyphResources.php b/src/Fonts/Color/GlyphResources.php index b468ada0a..6bd6b110a 100644 --- a/src/Fonts/Color/GlyphResources.php +++ b/src/Fonts/Color/GlyphResources.php @@ -3,9 +3,9 @@ namespace Mpdf\Fonts\Color; /** - * What a colour glyph's drawing can use besides paths and colours. The Type3 font a glyph is drawn in - * shares the document's resource dictionary, so each of these is registered where a page would - * register it. + * What a colour glyph's drawing can use besides paths and colours. Each is registered on the document, + * once however many glyphs use it, and named in the resource dictionary of the Type3 font the glyph is + * drawn in. */ interface GlyphResources { @@ -17,4 +17,11 @@ interface GlyphResources * pixels, its height in pixels], or null where the image cannot be drawn */ public function image($data); + + /** + * @param float $opacity From 0, transparent, to 1 + * + * @return string Content setting fills to that opacity, e.g. '/GS2 gs' + */ + public function alpha($opacity); } diff --git a/src/Fonts/Color/OutlineSource.php b/src/Fonts/Color/OutlineSource.php new file mode 100644 index 000000000..1bc572996 --- /dev/null +++ b/src/Fonts/Color/OutlineSource.php @@ -0,0 +1,34 @@ +file = $file; + } + + /** + * @inheritdoc + */ + public function draw($glyph, GlyphResources $resources) + { + $path = $this->file->outline()->path($glyph); + + return $path === '' ? null : $path . "f\n"; + } +} diff --git a/src/Fonts/Color/SbixSource.php b/src/Fonts/Color/SbixSource.php index 5e7e72d53..691bd878f 100644 --- a/src/Fonts/Color/SbixSource.php +++ b/src/Fonts/Color/SbixSource.php @@ -2,11 +2,8 @@ namespace Mpdf\Fonts\Color; -use Mpdf\Fonts\FileReader; use Mpdf\Fonts\FontReader; use Mpdf\Log\Context as LogContext; -use Mpdf\TTFontFile; -use Psr\Log\LoggerInterface; /** * Colour glyphs as bitmaps in sbix: Apple Color Emoji's format. @@ -40,21 +37,19 @@ class SbixSource extends BitmapSource private $numGlyphs = 0; /** - * @param TTFontFile $font The font, its table directory read - * @param FileReader $reader The font file - * @param int $unitsPerEm - * @param LoggerInterface $logger Told of a record in a graphic type mPDF does not draw + * @param ColorFontFile $file The font, whose logger is told of a record in a graphic type mPDF does + * not draw */ - public function __construct(TTFontFile $font, FileReader $reader, $unitsPerEm, LoggerInterface $logger) + public function __construct(ColorFontFile $file) { - parent::__construct($reader, $logger); + parent::__construct($file); - $maxp = $this->reader->fieldsAt($font->getTablePosition('maxp')[0] + 4, 2, 'n'); + $maxp = $this->reader->fieldsAt($file->table('maxp')[0] + 4, 2, 'n'); if ($maxp !== null) { $this->numGlyphs = $maxp[0]; } - list($sbix, $sbixLength) = $font->getTablePosition('sbix'); + list($sbix, $sbixLength) = $file->table('sbix'); $this->sbixEnd = $sbix + $sbixLength; // The strike offsets end with the table, if not before @@ -72,7 +67,7 @@ public function __construct(TTFontFile $font, FileReader $reader, $unitsPerEm, L } if ($ppem) { - $this->scale = $unitsPerEm / $ppem; + $this->scale = $file->unitsPerEm / $ppem; } } diff --git a/src/Fonts/FontSubsetter.php b/src/Fonts/FontSubsetter.php index 663f45727..a427f4f35 100644 --- a/src/Fonts/FontSubsetter.php +++ b/src/Fonts/FontSubsetter.php @@ -2,6 +2,8 @@ namespace Mpdf\Fonts; +use Mpdf\Fonts\Table\Loca; + // Work out the profile tables - head's bounding box, maxp's point and contour maxima, OS/2's // character range - from the glyphs the subset actually holds, rather than copying what the original // font stated. A host can set it before mPDF loads; nothing in mPDF sets it. @@ -568,7 +570,7 @@ private function buildSubsetSIP(array $subset, $useOTL) $glyphSet[$glyphIdx] = count($glyphMap); $glyphMap[] = $glyphIdx; } - $this->reader->skip(self::componentArgumentsLength($flags)); + $this->reader->skip(GlyphOperator::argumentsLength($flags)); } } } @@ -963,7 +965,7 @@ private function glyfTable(array $glyphMap, array $glyphSet) $up = unpack('n', substr($data, $pos_in_glyph + 2, 2)); $glyphIdx = $up[1]; $data = TableWriter::setUInt16($data, $pos_in_glyph + 2, $glyphSet[$glyphIdx]); - $pos_in_glyph += 4 + self::componentArgumentsLength($flags); + $pos_in_glyph += 4 + GlyphOperator::argumentsLength($flags); } } @@ -1077,7 +1079,7 @@ private function recalculatedProfile(array $glyphMap, $numberOfHMetrics) $nComponentElements += 1; $flags = $this->reader->readUInt16(); $this->glyphdata[$originalGlyphIdx]['compGlyphs'][] = $this->reader->readUInt16(); - $this->reader->skip(self::componentArgumentsLength($flags)); + $this->reader->skip(GlyphOperator::argumentsLength($flags)); } $profile['maxComponentElements'] = max($profile['maxComponentElements'], $nComponentElements); } else { @@ -1105,27 +1107,6 @@ private function recalculatedProfile(array $glyphMap, $numberOfHMetrics) return $profile; } - /** - * How many bytes of a compound glyph's component record follow its flags and glyph index: the two - * arguments, as words or bytes, then whichever transformation the flags say is there. - * - * @param int $flags The component's flags - */ - private static function componentArgumentsLength($flags) - { - $length = ($flags & GlyphOperator::WORDS) ? 4 : 2; - - if ($flags & GlyphOperator::SCALE) { - $length += 2; - } elseif ($flags & GlyphOperator::XYSCALE) { - $length += 4; - } elseif ($flags & GlyphOperator::TWOBYTWO) { - $length += 8; - } - - return $length; - } - /** * Move the reader to where a table starts, which is the start of the file where the font has none. * @@ -1151,23 +1132,7 @@ private function seekTable($tag) */ private function getLOCA($indexToLocFormat, $numGlyphs) { - $start = $this->seekTable('loca'); - $this->glyphPos = []; - if ($indexToLocFormat == 0) { - $data = $this->reader->bytesAt($start, ($numGlyphs * 2) + 2); - $arr = unpack("n*", $data); - for ($n = 0; $n <= $numGlyphs; $n++) { - $this->glyphPos[] = ($arr[$n + 1] * 2); - } - } elseif ($indexToLocFormat == 1) { - $data = $this->reader->bytesAt($start, ($numGlyphs * 4) + 4); - $arr = unpack("N*", $data); - for ($n = 0; $n <= $numGlyphs; $n++) { - $this->glyphPos[] = ($arr[$n + 1]); - } - } else { - throw new \Mpdf\Exception\FontException('Unknown location table format ' . $indexToLocFormat); - } + $this->glyphPos = Loca::offsets($this->reader, $this->seekTable('loca'), $indexToLocFormat, $numGlyphs); } /** @@ -1206,7 +1171,7 @@ private function getGlyphs($originalGlyphIdx, &$start, &$glyphSet, &$subsetglyph $savepos = $this->reader->tell(); $this->getGlyphs($glyphIdx, $start, $glyphSet, $subsetglyphs); $this->reader->seek($savepos); - $this->reader->skip(self::componentArgumentsLength($flags)); + $this->reader->skip(GlyphOperator::argumentsLength($flags)); } } } diff --git a/src/Fonts/FontSubstitution.php b/src/Fonts/FontSubstitution.php index a63968487..bf13a4d26 100644 --- a/src/Fonts/FontSubstitution.php +++ b/src/Fonts/FontSubstitution.php @@ -141,24 +141,42 @@ public function widths($family) return $this->drawnWidths($this->mpdf->fonts[$family]); } - // Only a font written as Type3 has its glyph map cached, and one of those draws nothing where - // the document may not draw colour - if (!ColorFormats::inColor($this->mpdf) && $this->fontCache->jsonHas($family . '.ctg.json')) { - return ''; + if (isset($this->widths[$family])) { + return $this->widths[$family]; } - if (!isset($this->widths[$family])) { - $cw = $this->fontCache->loadIfPresent($family . '.cw.dat'); - if (null === $cw) { - $this->loadFont($family); - - return isset($this->mpdf->fonts[$family]) ? $this->drawnWidths($this->mpdf->fonts[$family]) : null; + // Only a font written as Type3 has its glyph map cached, and one of those with no outlines draws + // nothing where the document may not draw colour. Metrics cached by another release are not + // trusted to say which, and the font is loaded instead, which caches them again. + if (!ColorFormats::inColor($this->mpdf) && $this->fontCache->jsonHas($family . '.ctg.json')) { + $metrics = $this->fontCache->jsonLoadIfPresent($family . '.mtx.json'); + if (!MetricsGenerator::isCurrent($metrics)) { + return $this->loadedWidths($family); + } + if (ColorFormats::blank($metrics, $this->mpdf)) { + return ''; } + } - $this->widths[$family] = $cw; + $cw = $this->fontCache->loadIfPresent($family . '.cw.dat'); + if (null === $cw) { + return $this->loadedWidths($family); } - return $this->widths[$family]; + return $this->widths[$family] = $cw; + } + + /** + * @param string $family The font's key in fontdata + * + * @return string|null What drawnWidths() gives for the font, loaded to find them, or null where there + * is no such font + */ + private function loadedWidths($family) + { + $this->loadFont($family); + + return isset($this->mpdf->fonts[$family]) ? $this->drawnWidths($this->mpdf->fonts[$family]) : null; } /** diff --git a/src/Fonts/GlyphOperator.php b/src/Fonts/GlyphOperator.php index 0a16990bb..667aa020f 100644 --- a/src/Fonts/GlyphOperator.php +++ b/src/Fonts/GlyphOperator.php @@ -7,6 +7,8 @@ class GlyphOperator const WORDS = 1 << 0; + const XY_VALUES = 1 << 1; + const SCALE = 1 << 3; const MORE = 1 << 5; @@ -14,4 +16,29 @@ class GlyphOperator const XYSCALE = 1 << 6; const TWOBYTWO = 1 << 7; + + const SCALED_OFFSET = 1 << 11; + + /** + * How many bytes of a compound glyph's component record follow its flags and glyph index: the two + * arguments, as words or bytes, then whichever transformation the flags say is there. + * + * @param int $flags The component's flags + * + * @return int + */ + public static function argumentsLength($flags) + { + $length = ($flags & self::WORDS) ? 4 : 2; + + if ($flags & self::SCALE) { + $length += 2; + } elseif ($flags & self::XYSCALE) { + $length += 4; + } elseif ($flags & self::TWOBYTWO) { + $length += 8; + } + + return $length; + } } diff --git a/src/Fonts/GlyphOutline.php b/src/Fonts/GlyphOutline.php new file mode 100644 index 000000000..447e1f8c6 --- /dev/null +++ b/src/Fonts/GlyphOutline.php @@ -0,0 +1,420 @@ +reader = $reader; + $this->logger = $logger; + $this->glyf = $font->getTablePosition('glyf')[0]; + // Each glyph's place in loca is read as it is drawn, since a document draws few of a colour + // font's tens of thousands + $this->loca = $font->getTablePosition('loca')[0]; + $this->indexToLocFormat = $reader->uint16At($font->getTablePosition('head')[0] + 50); + $this->numGlyphs = $reader->uint16At($font->getTablePosition('maxp')[0] + 4); + } + + /** + * @param int $glyph The glyph id + * + * @return string The path, or '' where the glyph has no outline + */ + public function path($glyph) + { + if (!isset($this->paths[$glyph])) { + $path = ''; + foreach ($this->contours($glyph, 0) as $contour) { + $path .= $this->contourPath($contour); + } + $this->paths[$glyph] = $path; + } + + return $this->paths[$glyph]; + } + + /** + * @param int $glyph The glyph id + * @param int $depth How many composites led here + * + * @return array[] Each contour as its points, each [x, y, whether it is on the curve] + */ + private function contours($glyph, $depth) + { + $range = $glyph < $this->numGlyphs && $depth <= self::MAX_DEPTH ? Loca::range($this->reader, $this->loca, $this->indexToLocFormat, $glyph) : null; + if ($range === null || $range[1] <= $range[0]) { + return []; + } + + // One read of the whole glyph, parsed from memory, rather than hundreds from the file + list($start, $end) = $range; + $length = $end - $start; + $bytes = $this->reader->bytesAt($this->glyf + $start, $length); + if (strlen($bytes) < $length || $length < 10) { + return []; + } + + $data = new BlobReader($bytes); + $numberOfContours = $data->readInt16(); + $data->skip(8); // the bounding box + + return $numberOfContours >= 0 ? $this->simpleContours($data, $numberOfContours) : $this->compositeContours($data, $depth); + } + + /** + * @param BlobReader $data The glyph, read to its contours + * @param int $numberOfContours + * + * @return array[] As contours() gives them + */ + private function simpleContours(BlobReader $data, $numberOfContours) + { + // endPtsOfContours, then instructionLength + if (!$numberOfContours || !$this->fits($data, 2 * $numberOfContours + 2)) { + return []; + } + + $ends = []; + for ($i = 0; $i < $numberOfContours; $i++) { + $ends[] = $data->readUInt16(); + // Each contour ends past the last + if ($i > 0 && $ends[$i] < $ends[$i - 1]) { + return []; + } + } + + $instructions = $data->readUInt16(); + if (!$this->fits($data, $instructions)) { + return []; + } + $data->skip($instructions); + + $count = end($ends) + 1; + $flags = []; + while (count($flags) < $count) { + if (!$this->fits($data, 1)) { + return []; + } + $flag = $data->readUInt8(); + $flags[] = $flag; + if ($flag & 0x08) { + if (!$this->fits($data, 1)) { + return []; + } + for ($repeat = $data->readUInt8(); $repeat > 0; $repeat--) { + $flags[] = $flag; + } + } + } + + $xs = $this->coordinates($data, $flags, $count, 0x02, 0x10); + $ys = $xs === null ? null : $this->coordinates($data, $flags, $count, 0x04, 0x20); + if ($ys === null) { + return []; + } + + $contours = []; + $start = 0; + foreach ($ends as $end) { + $contour = []; + for ($i = $start; $i <= $end; $i++) { + $contour[] = [$xs[$i], $ys[$i], (bool) ($flags[$i] & 0x01)]; + } + $contours[] = $contour; + $start = $end + 1; + } + + return $contours; + } + + /** + * One axis of a simple glyph's points: each a delta from the last, of one byte - its sign in the + * flag - or of two, or the same as the last where the flag says so + * + * @param BlobReader $data The glyph, read to this axis + * @param int[] $flags Each point's flags + * @param int $count How many points there are + * @param int $short The flag bit for a one-byte delta + * @param int $same The flag bit that, for a one-byte delta, says it is positive, and + * otherwise says the delta is 0 + * + * @return int[]|null Each point's coordinate, or null where the glyph ends before they do + */ + private function coordinates(BlobReader $data, array $flags, $count, $short, $same) + { + $bytes = 0; + for ($i = 0; $i < $count; $i++) { + $bytes += ($flags[$i] & $short) ? 1 : (($flags[$i] & $same) ? 0 : 2); + } + if (!$this->fits($data, $bytes)) { + return null; + } + + $values = []; + $value = 0; + for ($i = 0; $i < $count; $i++) { + if ($flags[$i] & $short) { + $delta = $data->readUInt8(); + $value += ($flags[$i] & $same) ? $delta : -$delta; + } elseif (!($flags[$i] & $same)) { + $value += $data->readInt16(); + } + $values[] = $value; + } + + return $values; + } + + /** + * @param BlobReader $data The glyph, read to its components + * @param int $depth How many composites led here + * + * @return array[] As contours() gives them: the components' contours, each placed + */ + private function compositeContours(BlobReader $data, $depth) + { + $contours = []; + + do { + // The flags and glyph id, then the arguments and transformation they say are there + if (!$this->fits($data, 4)) { + return $contours; + } + $flags = $data->readUInt16(); + $glyph = $data->readUInt16(); + + if (!$this->fits($data, GlyphOperator::argumentsLength($flags))) { + return $contours; + } + + $xy = $flags & GlyphOperator::XY_VALUES; + if ($flags & GlyphOperator::WORDS) { + $dx = $xy ? $data->readInt16() : $data->readUInt16(); + $dy = $xy ? $data->readInt16() : $data->readUInt16(); + } else { + $dx = $xy ? $data->readInt8() : $data->readUInt8(); + $dy = $xy ? $data->readInt8() : $data->readUInt8(); + } + + $a = $d = 1; + $b = $c = 0; + if ($flags & GlyphOperator::SCALE) { + $a = $d = $this->f2dot14($data); + } elseif ($flags & GlyphOperator::XYSCALE) { + $a = $this->f2dot14($data); + $d = $this->f2dot14($data); + } elseif ($flags & GlyphOperator::TWOBYTWO) { + $a = $this->f2dot14($data); + $b = $this->f2dot14($data); + $c = $this->f2dot14($data); + $d = $this->f2dot14($data); + } + + if (!$xy) { + $this->logger->warning(sprintf('Glyph %d is placed by matching points, which mPDF does not draw', $glyph), ['context' => LogContext::FONTS]); + continue; + } + + if ($flags & GlyphOperator::SCALED_OFFSET) { + list($dx, $dy) = [$a * $dx + $c * $dy, $b * $dx + $d * $dy]; + } + + foreach ($this->contours($glyph, $depth + 1) as $contour) { + foreach ($contour as $i => $point) { + list($x, $y) = $point; + $contour[$i][0] = $a * $x + $c * $y + $dx; + $contour[$i][1] = $b * $x + $d * $y + $dy; + } + $contours[] = $contour; + } + } while ($flags & GlyphOperator::MORE); + + return $contours; + } + + /** + * @param BlobReader $data A glyph + * @param int $bytes How many bytes are to be read next + * + * @return bool Whether the glyph holds them + */ + private function fits(BlobReader $data, $bytes) + { + return $data->tell() + $bytes <= $data->length(); + } + + /** + * @param BlobReader $data A glyph, read to the number + * + * @return float F2DOT14: a signed 2.14 fixed point number + */ + private function f2dot14(BlobReader $data) + { + return $data->readInt16() / 16384; + } + + /** + * One contour as a closed subpath. + * + * It starts at an on-curve point: the first there is, or, where every point is off the curve, the + * point midway between the last and the first. + * + * @return string + */ + private function contourPath(array $points) + { + $count = count($points); + if ($count < 2) { + return ''; + } + + $first = 0; + while ($first < $count && !$points[$first][2]) { + $first++; + } + + if ($first === $count) { + // Start between the last point and the first, where FreeType and fontTools start + array_unshift($points, $this->midpoint($points[$count - 1], $points[0])); + $count++; + } else { + $points = array_merge(array_slice($points, $first), array_slice($points, 0, $first)); + } + + $current = $points[0]; + $path = $this->number($current[0]) . ' ' . $this->number($current[1]) . " m\n"; + + $control = null; + for ($i = 1; $i <= $count; $i++) { + $point = $points[$i % $count]; + + if ($point[2]) { + // A straight edge back to the start is drawn by h + if ($control === null && $i === $count) { + break; + } + $path .= $control === null + ? $this->number($point[0]) . ' ' . $this->number($point[1]) . " l\n" + : $this->curve($current, $control, $point); + $current = $point; + $control = null; + } elseif ($control === null) { + $control = $point; + } else { + $implied = $this->midpoint($control, $point); + $path .= $this->curve($current, $control, $implied); + $current = $implied; + $control = $point; + } + } + + return $path . "h\n"; + } + + /** + * The quadratic Bezier from $from through $control to $to, as the cubic that draws it + * + * @return string + */ + private function curve(array $from, array $control, array $to) + { + return $this->number($from[0] + 2 / 3 * ($control[0] - $from[0])) . ' ' + . $this->number($from[1] + 2 / 3 * ($control[1] - $from[1])) . ' ' + . $this->number($to[0] + 2 / 3 * ($control[0] - $to[0])) . ' ' + . $this->number($to[1] + 2 / 3 * ($control[1] - $to[1])) . ' ' + . $this->number($to[0]) . ' ' . $this->number($to[1]) . " c\n"; + } + + /** + * @return array An on-curve point midway between two others + */ + private function midpoint(array $p, array $q) + { + return [($p[0] + $q[0]) / 2, ($p[1] + $q[1]) / 2, true]; + } + + /** + * @return string A coordinate as a path writes it, to three places and no more than it needs + */ + private function number($value) + { + // A simple glyph's points are whole font units + if (is_int($value)) { + return (string) $value; + } + + $number = rtrim(rtrim(sprintf('%.3F', $value), '0'), '.'); + + return $number === '-0' ? '0' : $number; + } +} diff --git a/src/Fonts/MetricsGenerator.php b/src/Fonts/MetricsGenerator.php index a80ef728e..cc3271e79 100644 --- a/src/Fonts/MetricsGenerator.php +++ b/src/Fonts/MetricsGenerator.php @@ -16,7 +16,19 @@ class MetricsGenerator * holds, the keys of an array — is served the old shape and reads it as the new one. Raise this * whenever that happens. Mpdf::AddFont() compares it and regenerates on a mismatch. */ - const CACHE_FORMAT = 6; + const CACHE_FORMAT = 7; + + /** + * Whether cached metrics are in the shape this release writes - see CACHE_FORMAT + * + * @param array|null $metrics A font's metrics, as the cache held them + * + * @return bool + */ + public static function isCurrent($metrics) + { + return isset($metrics['cacheFormat']) && $metrics['cacheFormat'] === self::CACHE_FORMAT; + } private $fontCache; @@ -77,6 +89,8 @@ public function generateMetrics($ttffile, $ttfstat, $fontkey, $TTCfontID, $debug 'GPOSLookups' => $ttf->GPOSLookups, 'kerninfo' => $ttf->kerninfo, 'colorFormats' => $ttf->colorFormats, + 'hasOutlines' => $ttf->hasOutlines, + 'selectorsInSequences' => $ttf->selectorsInSequences, 'tagChars' => $ttf->tagChars, 'cacheFormat' => self::CACHE_FORMAT, ]; diff --git a/src/Fonts/Table/GsubOutputs.php b/src/Fonts/Table/GsubOutputs.php index c31226ef7..ed01c26c6 100644 --- a/src/Fonts/Table/GsubOutputs.php +++ b/src/Fonts/Table/GsubOutputs.php @@ -41,6 +41,54 @@ public static function glyphs(FontReader $reader, array $lookups) return $glyphs; } + /** + * Whether a glyph is among the components of any ligature after its first, which is where U+FE0F + * stands in a sequence that includes it + * + * @param FontReader $reader The font + * @param array $lookups As glyphs() takes them + * @param int $glyph The glyph id + * + * @return bool + */ + public static function inLigatures(FontReader $reader, array $lookups, $glyph) + { + foreach ($lookups as $lookup) { + if ($lookup['Type'] != 4) { + continue; + } + foreach ($lookup['Subtables'] as $subtable) { + foreach (self::ligatures($reader, $subtable) as $ligature) { + // ligatureGlyph, componentCount, then the components after the first + $reader->seek($ligature + 2); + if (in_array($glyph, SequenceRule::values($reader, $reader->readUInt16() - 1), true)) { + return true; + } + } + } + } + + return false; + } + + /** + * @param FontReader $reader The font + * @param int $subtable A Ligature Substitution subtable's absolute offset + * + * @return int[] Where each of its Ligature tables starts + */ + private static function ligatures(FontReader $reader, $subtable) + { + // ligatureSetCount, past the format and coverageOffset + $reader->seek($subtable + 4); + $sets = [[]]; + foreach (SequenceRule::coverageOffsets($reader, $subtable, $reader->readUInt16()) as $set) { + $sets[] = SequenceRule::ruleOffsets($reader, $set); + } + + return call_user_func_array('array_merge', $sets); + } + /** * @param FontReader $reader The font * @param int $type The lookup type, extensions resolved @@ -81,13 +129,10 @@ private static function outputs(FontReader $reader, $type, $subtable) return $outputs; case 4: - $reader->skip(2); // coverageOffset $outputs = []; - foreach (SequenceRule::coverageOffsets($reader, $subtable, $reader->readUInt16()) as $set) { - foreach (SequenceRule::ruleOffsets($reader, $set) as $ligature) { - $reader->seek($ligature); - $outputs[] = $reader->readUInt16(); - } + foreach (self::ligatures($reader, $subtable) as $ligature) { + $reader->seek($ligature); + $outputs[] = $reader->readUInt16(); } return $outputs; diff --git a/src/Fonts/Table/Loca.php b/src/Fonts/Table/Loca.php new file mode 100644 index 000000000..8d93fd275 --- /dev/null +++ b/src/Fonts/Table/Loca.php @@ -0,0 +1,64 @@ + where it starts in glyf, with one past the end appended + * + * @throws \Mpdf\Exception\FontException If head names a format that is neither + */ + public static function offsets(FileReader $reader, $loca, $indexToLocFormat, $numGlyphs) + { + if ($indexToLocFormat == 0) { + $offsets = []; + foreach (unpack('n*', $reader->bytesAt($loca, ($numGlyphs + 1) * 2)) as $offset) { + $offsets[] = $offset * 2; + } + + return $offsets; + } + + if ($indexToLocFormat == 1) { + return array_values(unpack('N*', $reader->bytesAt($loca, ($numGlyphs + 1) * 4))); + } + + throw new \Mpdf\Exception\FontException('Unknown location table format ' . $indexToLocFormat); + } + + /** + * Where one glyph starts and ends in glyf, read on its own rather than with the whole table + * + * @param int $loca Where loca starts + * @param int $indexToLocFormat head's: 0 for the short format, 1 for the long + * @param int $glyph The glyph id + * + * @return int[]|null [start, end], or null where loca ends first or head names a format that is + * neither + */ + public static function range(FontReader $reader, $loca, $indexToLocFormat, $glyph) + { + if ($indexToLocFormat == 0) { + $range = $reader->fieldsAt($loca + $glyph * 2, 4, 'n2'); + + return $range === null ? null : [$range[0] * 2, $range[1] * 2]; + } + + return $indexToLocFormat == 1 ? $reader->fieldsAt($loca + $glyph * 4, 8, 'N2') : null; + } +} diff --git a/src/Mpdf.php b/src/Mpdf.php index 0e3adbb24..965c12a83 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -4003,6 +4003,8 @@ function AddFont($family, $style = '') 'GPOSLookups' => [], 'rtlPUAstr' => '', 'colorFormats' => [], + 'hasOutlines' => false, + 'selectorsInSequences' => false, 'tagChars' => [], 'cacheFormat' => 0, ]; @@ -4040,8 +4042,7 @@ function AddFont($family, $style = '') } // mPDF 6 // A cache written by a release that laid its files out differently cannot be read by this one - $cacheFormat = isset($font['cacheFormat']) ? $font['cacheFormat'] : 0; - if ($cacheFormat !== MetricsGenerator::CACHE_FORMAT) { + if (!MetricsGenerator::isCurrent($font)) { $regenerate = true; } @@ -4138,6 +4139,8 @@ function AddFont($family, $style = '') 'haskernGPOS' => $font['haskernGPOS'], 'hassmallcapsGSUB' => $font['hassmallcapsGSUB'], 'colorFormats' => $font['colorFormats'], + 'hasOutlines' => $font['hasOutlines'], + 'selectorsInSequences' => $font['selectorsInSequences'], 'ligatureText' => [], 'tagChars' => $font['tagChars'], ]; @@ -23456,9 +23459,16 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl /////////////////////////END OF TABLE CODE////////////////////////////////// /* -- END TABLES -- */ + /** + * Writes each graphics state not written yet: a colour font's glyphs register theirs as the fonts + * are written, after the rest have been + */ function _putextgstates() { for ($i = 1; $i <= count($this->extgstates); $i++) { + if (isset($this->extgstates[$i]['n'])) { + continue; + } $this->writer->object(); $this->extgstates[$i]['n'] = $this->n; $this->writer->write('<OTLdata = []; } // END foreach subchunk // 11. Re-assemble and return text string - return $this->reassemble($subchunk); + $shaped = $this->reassemble($subchunk); + + // A selector kept for the ligatures to match, and taken into none, draws nothing, as HarfBuzz + // hides it. Both selectors start with the same two bytes. + if (!empty($this->mpdf->CurrentFont['selectorsInSequences']) && strpos($shaped, "\xEF\xB8") !== false) { + foreach (Emoji::SELECTORS_UTF8 as $selector) { + OtlData::removeChar($shaped, $this->OTLdata, $selector, 'UTF-8'); + } + } + + return $shaped; } /** * What HarfBuzz does to an emoji before GSUB sees it. * * A presentation selector chooses a font rather than a glyph, and mPDF chose the font before the - * text got here. HarfBuzz hides a selector the font has no variant for, which is every selector to - * a shaper that reads no cmap format 14, and emoji fonts leave them out of the sequences their - * ligatures match: Noto forms a keycap from the digit and U+20E3 alone, and would not form it with - * the U+FE0F still between them. + * text got here. HarfBuzz lets a ligature match with or without a selector, which the shaper here + * cannot, so a font's ligatures decide: Noto leaves U+FE0F out of its sequences - it forms a keycap + * from the digit and U+20E3 alone - and the selectors are taken out of the text; Twemoji puts U+FE0F + * in, and they are kept. * * A tag is handed the code the parser read its glyph at - see TTFontFile::$tagChars. * @@ -319,9 +330,11 @@ function applyOTL($str, $useOTL) */ private function prepareEmoji($str) { - $str = str_replace(["\xef\xb8\x8e", "\xef\xb8\x8f"], '', $str); - $font = $this->mpdf->CurrentFont; + if (empty($font['selectorsInSequences'])) { + $str = str_replace(Emoji::SELECTORS_UTF8, '', $str); + } + if (!empty($font['tagChars']) && preg_match('/[\x{E0020}-\x{E007F}]/u', $str)) { if (!isset($this->tagAliases[$font['fontkey']])) { $aliases = []; diff --git a/src/TTFontFile.php b/src/TTFontFile.php index 47713b3a0..a205358fc 100644 --- a/src/TTFontFile.php +++ b/src/TTFontFile.php @@ -9,6 +9,7 @@ use Mpdf\Fonts\Table\ClassDef; use Mpdf\Fonts\Table\Coverage; use Mpdf\Fonts\Table\GsubOutputs; +use Mpdf\Fonts\Table\Loca; use Mpdf\Fonts\Table\LookupFlag; use Mpdf\Fonts\Table\SequenceRule; use Mpdf\Fonts\TableChecksum; @@ -252,6 +253,21 @@ class TTFontFile implements Fonts\FontSourceInterface */ public $colorFormats = []; + /** + * Whether any glyph has a glyf outline, which a colour font of bitmaps alone does not + * + * @var bool + */ + public $hasOutlines = false; + + /** + * Whether the font's ligatures are formed with U+FE0F among their components, as Twemoji's are, + * where Noto's leave it out. See Otl::prepareEmoji(). + * + * @var bool + */ + public $selectorsInSequences = false; + /** * Each tag character the font maps, and the character the shaper reads its glyph as. * @@ -331,6 +347,8 @@ public function getMetrics($file, $fontkey, $TTCfontID = 0, $debug = false, $BMP $this->strikeoutPosition = 0; $this->restrictedUse = false; $this->colorFormats = []; + $this->hasOutlines = false; + $this->selectorsInSequences = false; $this->tagChars = []; $this->open($file); @@ -1139,6 +1157,7 @@ function extractInfo($debug = false, $BMPonly = false, $useOTL = 0) $this->smpset = $smpset; $this->colorFormats = $this->colorFormats(); + $this->hasOutlines = $this->hasOutlines($numGlyphs, $indexToLocFormat); // A font written as Type3 is encoded in subsets of 255 characters, as one of the Supplementary // Multilingual Plane is @@ -1146,11 +1165,20 @@ function extractInfo($debug = false, $BMPonly = false, $useOTL = 0) $this->smpset = true; } + // GSUB's lookups, read once for both questions asked of them + $drawable = $this->useOTL && ColorFormats::drawable($this->colorFormats); + $selector = $this->useOTL && isset($charToGlyph[Emoji::EMOJI_SELECTOR]); + $lookups = $drawable || $selector ? $this->gsubLookups() : []; + + if ($selector) { + $this->selectorsInSequences = GsubOutputs::inLigatures($this->reader, $lookups, $charToGlyph[Emoji::EMOJI_SELECTOR]); + } + // A font written as Type3 hands codes only to the glyphs GSUB can output, and to its tags, which // text reaches through $tagChars: the rest are drawn inside other glyphs, never as text $reachable = null; - if ($this->useOTL && ColorFormats::drawable($this->colorFormats)) { - $reachable = array_fill_keys($tagGlyphs, true) + $this->gsubOutputs(); + if ($drawable) { + $reachable = array_fill_keys($tagGlyphs, true) + GsubOutputs::glyphs($this->reader, $lookups); } // Map Unmapped glyphs (or glyphs mapped to upper PUA U+F00000 onwards i.e. > U+2FFFF) - from $numGlyphs @@ -1294,11 +1322,11 @@ function extractInfo($debug = false, $BMPonly = false, $useOTL = 0) } /** - * Every glyph GSUB can output + * GSUB's lookups, as GsubOutputs reads them * - * @return true[] Glyph id => true + * @return array */ - private function gsubOutputs() + private function gsubLookups() { if (!$this->hasTable('GSUB')) { return []; @@ -1306,9 +1334,29 @@ private function gsubOutputs() $gsubOffset = $this->seek_table('GSUB'); list(, , $lookupList) = $this->readListOffsets($gsubOffset); - $lookups = $this->absoluteSubtables($this->readLookupList($lookupList, $gsubOffset, 7), $gsubOffset); - return GsubOutputs::glyphs($this->reader, $lookups); + return $this->absoluteSubtables($this->readLookupList($lookupList, $gsubOffset, 7), $gsubOffset); + } + + /** + * Whether glyf holds any outline: where the last glyph ends is where glyf's data ends, which is 0 + * where every glyph is empty, as in a font of bitmaps with a glyf table only because one is + * required + * + * @param int $numGlyphs maxp's glyph count + * @param int $indexToLocFormat head's: 0 for loca's short format, 1 for the long + * + * @return bool + */ + private function hasOutlines($numGlyphs, $indexToLocFormat) + { + if (!$this->hasTable('glyf') || !$this->hasTable('loca')) { + return false; + } + + $last = $numGlyphs > 0 ? Loca::range($this->reader, $this->get_table_pos('loca')[0], $indexToLocFormat, $numGlyphs - 1) : null; + + return $last !== null && $last[1] > 0; } /** diff --git a/src/Unicode/Emoji.php b/src/Unicode/Emoji.php index 8a6e669b5..f5945972d 100644 --- a/src/Unicode/Emoji.php +++ b/src/Unicode/Emoji.php @@ -30,6 +30,11 @@ class Emoji const CANCEL_TAG = 0xE007F; + /** + * U+FE0E and U+FE0F as UTF-8 + */ + const SELECTORS_UTF8 = ["\xEF\xB8\x8E", "\xEF\xB8\x8F"]; + /** * Asks for colour: Emoji_Presentation, U+FE0F, or any sequence longer than one emoji */ diff --git a/src/Writer/Type3FontWriter.php b/src/Writer/Type3FontWriter.php index 92c9c086d..62457cfb3 100644 --- a/src/Writer/Type3FontWriter.php +++ b/src/Writer/Type3FontWriter.php @@ -2,6 +2,7 @@ namespace Mpdf\Writer; +use Mpdf\Fonts\Color\ColorFontFile; use Mpdf\Fonts\Color\ColorFormats; use Mpdf\Fonts\Color\ColorGlyphSource; use Mpdf\Fonts\Color\GlyphResources; @@ -24,12 +25,14 @@ * the space word spacing stretches. * * Each glyph procedure starts d0 rather than d1, which is what lets it set colours. One that sets no - * colour is drawn in the colour of the text. + * colour is drawn in the colour of the text. A glyph is drawn by the first source that has it: the + * colour format, then the glyph's outline, so a glyph with no colour of its own - a digit, say - is + * its outline in the colour of the text. * - * Each subset has a resource dictionary of its own, listing the images its glyphs draw. It cannot be - * the page's: that lists the font itself, which Acrobat refuses to load. The images are written after - * the fonts, so the dictionary's object number is set aside as the subset is written and the - * dictionary is written once the images have numbers - see writeResources(). + * Each subset has a resource dictionary of its own, listing the images and graphics states its glyphs + * draw with. It cannot be the page's: that lists the font itself, which Acrobat refuses to load. The + * images are written after the fonts, so the dictionary's object number is set aside as the subset is + * written and the dictionary is written once the images have numbers - see writeResources(). * * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf 9.6.5 */ @@ -74,14 +77,14 @@ class Type3FontWriter implements GlyphResources private $undrawable = []; /** - * @var string[] The keys in Mpdf::$images of the images drawn by the glyphs of the subset being - * written, each once + * @var string[][] What the glyphs of the subset being written draw with, each once: 'images', keys + * in Mpdf::$images, and 'states', numbers in Mpdf::$extgstates */ private $drawn = []; /** - * @var string[][] Each resource dictionary still to be written, by the object number set aside for - * it: the images its subset's glyphs draw + * @var string[][][] Each resource dictionary still to be written, by the object number set aside + * for it: what its subset's glyphs draw with, as $drawn holds it */ private $resources = []; @@ -104,8 +107,9 @@ public function __construct(Mpdf $mpdf, BaseWriter $writer, FontCache $fontCache /** * Writes each subset of one colour font, and records the object number of each on the font. * - * Where the document may not draw colour, each glyph is written as a procedure that draws nothing - - * see ColorFormats::blank() - so the text keeps its place and still copies out. + * Where the document may not draw colour, each glyph is drawn from its outline, and where the font + * has none as a procedure that draws nothing - see ColorFormats::blank() - so the text keeps its + * place and still copies out. * * @param string $k The font's key in Mpdf::$fonts * @param array $font The font @@ -115,10 +119,10 @@ public function writeFont($k, array $font) $this->fontkey = $font['fontkey']; $charToGlyph = $this->fontCache->jsonLoad($font['fontkey'] . '.ctg.json'); $text = $this->ligatureText($font); - $format = ColorFormats::drawn($font, $this->mpdf); + $classes = ColorFormats::sources($font, $this->mpdf); - if ($format === '') { - $this->writeSubsets($k, $font, null, $charToGlyph, $text); + if (!$classes) { + $this->writeSubsets($k, $font, [], $charToGlyph, $text); return; } @@ -127,7 +131,13 @@ public function writeFont($k, array $font) $reader = $ttf->openFont($font['ttffile'], $font['TTCfontID']); try { - $this->writeSubsets($k, $font, ColorFormats::source($format, $ttf, $reader, $font['unitsPerEm'], $this->logger), $charToGlyph, $text); + $file = new ColorFontFile($ttf, $reader, $font['unitsPerEm'], $this->logger); + $sources = []; + foreach ($classes as $class) { + $sources[] = new $class($file); + } + + $this->writeSubsets($k, $font, $sources, $charToGlyph, $text); } finally { $reader->close(); } @@ -136,16 +146,17 @@ public function writeFont($k, array $font) /** * Writes each subset of a font, and records the object number of each on the font * - * @param string $k The font's key in Mpdf::$fonts - * @param array $font The font - * @param ColorGlyphSource|null $source What draws each glyph, or null where none is drawn - * @param int[] $charToGlyph Character => glyph, Private Use codes included - * @param int[][] $ligatureText Ligature character => the characters it was formed from + * @param string $k The font's key in Mpdf::$fonts + * @param array $font The font + * @param ColorGlyphSource[] $sources What draws a glyph, the first that has it; none where + * every glyph is drawn blank + * @param int[] $charToGlyph Character => glyph, Private Use codes included + * @param int[][] $ligatureText Ligature character => the characters it was formed from */ - private function writeSubsets($k, array $font, $source, array $charToGlyph, array $ligatureText) + private function writeSubsets($k, array $font, array $sources, array $charToGlyph, array $ligatureText) { foreach ($font['subsetfontids'] as $sfid => $fid) { - $this->mpdf->fonts[$k]['n'][$sfid] = $this->writeSubset($font, $font['subsets'][$sfid], $source, $charToGlyph, $ligatureText); + $this->mpdf->fonts[$k]['n'][$sfid] = $this->writeSubset($font, $font['subsets'][$sfid], $sources, $charToGlyph, $ligatureText); } } @@ -197,12 +208,27 @@ public function image($data) $this->mpdf->images[$key] = $image + ['i' => count($this->mpdf->images) + 1]; } - $this->drawn[$key] = $key; + $this->drawn['images'][$key] = $key; $image = $this->mpdf->images[$key]; return ['/I' . $image['i'], $image['w'], $image['h']]; } + /** + * Registers a graphics state filling at an opacity, once however many glyphs fill at it + * + * @param float $opacity From 0, transparent, to 1 + * + * @return string Content setting fills to that opacity, e.g. '/GS2 gs' + */ + public function alpha($opacity) + { + $state = $this->mpdf->AddExtGState(['BM' => '/Normal', 'ca' => $opacity]); + $this->drawn['states'][$state] = $state; + + return sprintf('/GS%d gs', $state); + } + /** * @throws \Mpdf\MpdfException Where the JPEG's size and colour space cannot be read * @@ -257,24 +283,55 @@ private function png($data, $key) /** * Writes the resource dictionary of each subset written here, under the object number set aside for - * it. Called once the images are written, which is when they have object numbers to be named by. + * it. Called once the images are written, which is when they have object numbers to be named by, + * and writes the graphics states the glyphs registered after the document's were written. */ public function writeResources() { - foreach ($this->resources as $object => $keys) { - $images = ''; - foreach ($keys as $key) { - $images .= '/I' . $this->mpdf->images[$key]['i'] . ' ' . $this->mpdf->images[$key]['n'] . ' 0 R '; + if (!$this->resources) { + return; + } + + $this->mpdf->_putextgstates(); + + foreach ($this->resources as $object => $drawn) { + $images = []; + foreach ($drawn['images'] as $key) { + $images['I' . $this->mpdf->images[$key]['i']] = $this->mpdf->images[$key]['n']; + } + $states = []; + foreach ($drawn['states'] as $state) { + $states['GS' . $state] = $this->mpdf->extgstates[$state]['n']; } $this->writer->object($object); - $this->writer->write('<>>>'); + $this->writer->write('<<' . $this->dictionary('XObject', $images) . $this->dictionary('ExtGState', $states) . '>>'); $this->writer->write('endobj'); } $this->resources = []; } + /** + * @param string $type A kind of resource, e.g. 'XObject' + * @param int[] $objects Name => the object number it names + * + * @return string The entry of a resource dictionary naming them, or nothing where there are none + */ + private function dictionary($type, array $objects) + { + if (!$objects) { + return ''; + } + + $entries = ''; + foreach ($objects as $name => $object) { + $entries .= '/' . $name . ' ' . $object . ' 0 R '; + } + + return '/' . $type . ' <<' . $entries . '>>'; + } + /** * @param string $data A PNG * @param string $key What to keep the decoded image under in the font cache @@ -336,15 +393,15 @@ private function ligatureText(array $font) } /** - * @param array $font The font, as Mpdf::$fonts holds it - * @param int[] $subset Byte => the character it is written for - * @param ColorGlyphSource|null $source What draws each glyph, or null where none is drawn - * @param int[] $charToGlyph Character => glyph, Private Use codes included - * @param int[][] $ligatureText Ligature character => the characters it was formed from + * @param array $font The font, as Mpdf::$fonts holds it + * @param int[] $subset Byte => the character it is written for + * @param ColorGlyphSource[] $sources What draws a glyph, the first that has it + * @param int[] $charToGlyph Character => glyph, Private Use codes included + * @param int[][] $ligatureText Ligature character => the characters it was formed from * * @return int The object number of the font */ - private function writeSubset(array $font, array $subset, $source, array $charToGlyph, array $ligatureText) + private function writeSubset(array $font, array $subset, array $sources, array $charToGlyph, array $ligatureText) { // Glyph space is font units, so widths laid out in thousandths of an em are scaled into it $scale = $font['unitsPerEm'] / 1000; @@ -353,7 +410,7 @@ private function writeSubset(array $font, array $subset, $source, array $charToG $widths = []; $procedures = []; $differences = ''; - $this->drawn = []; + $this->drawn = ['images' => [], 'states' => []]; foreach ($subset as $code => $char) { $width = '0.000'; if ($char && isset($charToGlyph[$char])) { @@ -362,15 +419,15 @@ private function writeSubset(array $font, array $subset, $source, array $charToG $width = sprintf('%.3F', $this->mpdf->_getCharWidth($font['cw'], $char, false) * $scale); $differences .= $code . ' /g' . $glyph . ' '; if (!isset($procedures[$glyph])) { - $procedures[$glyph] = $width . " 0 d0\n" . ($source ? $source->draw($glyph, $this) : ''); + $procedures[$glyph] = $width . " 0 d0\n" . $this->draw($glyph, $sources); } } $widths[] = $width; } - // A subset whose glyphs draw no image - one drawn blank, say - names no resource + // A subset whose glyphs draw no image nor graphics state - one drawn blank, say - names no resource $resources = '<<>>'; - if ($this->drawn) { + if ($this->drawn['images'] || $this->drawn['states']) { $this->writer->object(false, true); $this->resources[$this->mpdf->n] = $this->drawn; $resources = $this->mpdf->n . ' 0 R'; @@ -423,6 +480,24 @@ private function writeSubset(array $font, array $subset, $source, array $charToG return $fontObject; } + /** + * @param int $glyph The glyph id + * @param ColorGlyphSource[] $sources What draws a glyph, the first that has it + * + * @return string The glyph as the first source that has it draws it, or nothing + */ + private function draw($glyph, array $sources) + { + foreach ($sources as $source) { + $content = $source->draw($glyph, $this); + if ($content !== null) { + return $content; + } + } + + return ''; + } + /** * What a reader copies out for each byte: the character it was written for, and for a ligature the * characters the ligature was formed from, so a copied family is the ZWJ sequence it was typed as. diff --git a/tests/Mpdf/ColorFontTest.php b/tests/Mpdf/ColorFontTest.php index 5d1c0eba8..3696ea256 100644 --- a/tests/Mpdf/ColorFontTest.php +++ b/tests/Mpdf/ColorFontTest.php @@ -28,6 +28,7 @@ private function mpdf(array $config = []) 'fontdata' => [ 'cbdt' => ['R' => 'TestEmoji-CBDT.ttf', 'useOTL' => 0xFF], 'sbix' => ['R' => 'TestEmoji-sbix.ttf', 'useOTL' => 0xFF], + 'colr' => ['R' => 'TestEmoji-COLRv0.ttf', 'useOTL' => 0xFF], 'notoemoji' => ['R' => 'NotoEmoji-Regular.ttf'], ], 'default_font' => 'cbdt', @@ -48,6 +49,16 @@ private function objects(array $codepoints, array $config = []) $mpdf = $this->mpdf($config); $mpdf->WriteHTML('

' . implode('', array_map('Mpdf\Utils\UtfString::code2utf', $codepoints)) . '

'); + return $this->objectsOf($mpdf); + } + + /** + * @param Mpdf $mpdf A document, its content written + * + * @return string[] Its objects, by number + */ + private function objectsOf(Mpdf $mpdf) + { preg_match_all('/(?:^|\n)(\d+) 0 obj\n(.*?)\nendobj/s', $mpdf->OutputBinaryData(), $matches, PREG_SET_ORDER); $objects = []; @@ -86,6 +97,20 @@ private function referenced(array $objects, $font, $key) return $objects[(int) $match[1]]; } + /** + * @param string[] $objects The document's objects, by number + * @param int $glyph A glyph of the document's one Type3 font + * + * @return string The body of the glyph's procedure + */ + private function procedure(array $objects, $glyph) + { + $procedures = $this->referenced($objects, $this->objectMatching($objects, '/\/Subtype \/Type3/'), 'CharProcs'); + $this->assertSame(1, preg_match('/\/g' . $glyph . ' (\d+) 0 R/', $procedures, $match), 'glyph ' . $glyph); + + return $objects[(int) $match[1]]; + } + /** * The font is scaled from font units by its matrix, and draws its glyphs' images from a resource * dictionary of its own. The page's would list the font itself, which Acrobat refuses to load. @@ -192,6 +217,52 @@ public function testAnSbixJpegIsWrittenAsItStandsAndADupeSharesItsImage() $this->assertCount(3, preg_grep('/\/Subtype \/Image/', $objects), 'the JPEG, and the man and his mask once'); } + /** + * The number sign of TestEmoji-COLRv0 has no layers, so it is its outline in the colour of the + * text; the heart's highlight is half-opaque, through a graphics state the font's own resources name + */ + public function testAColrGlyphIsItsLayersAndAPlainGlyphItsOutline() + { + $objects = $this->objects([0x23, 0x2764], ['default_font' => 'colr']); + + $numberSign = $this->procedure($objects, 2); + $this->assertStringContainsString("600.000 0 d0\n100 0 m\n100 700 l\n", $numberSign); + $this->assertStringNotContainsString(' rg', $numberSign); + + $this->assertSame(1, preg_match('/q \/GS(\d+) gs 1\.000 1\.000 1\.000 rg/', $this->procedure($objects, 13), $gs)); + + $resources = $this->referenced($objects, $this->objectMatching($objects, '/\/Subtype \/Type3/'), 'Resources'); + $this->assertSame(1, preg_match('/\/ExtGState <<\/GS' . $gs[1] . ' (\d+) 0 R >>/', $resources, $state)); + $this->assertStringNotContainsString('/XObject', $resources, 'the font draws no image'); + $this->assertMatchesRegularExpression('/\/Type \/ExtGState\s*\/BM \/Normal\s*\/ca 0\.50/', $objects[(int) $state[1]]); + } + + /** + * Where the document may not draw colour, a COLR font is drawn from its outlines, with no colour at + * all, and there is nothing to warn of + * + * @dataProvider restrictions + * + * @param array $config What keeps colour out + */ + public function testAColrFontIsDrawnFromItsOutlinesWhereColourIsOff(array $config) + { + $logger = new TestLogger(); + $mpdf = $this->mpdf($config + ['default_font' => 'colr']); + $mpdf->setLogger($logger); + $mpdf->WriteHTML('

' . UtfString::code2utf(0x1F600) . '

'); + + $objects = $this->objectsOf($mpdf); + + $face = $this->procedure($objects, 12); + $this->assertStringContainsString(' c', $face); + $this->assertStringNotContainsString(' rg', $face); + $this->assertStringNotContainsString(' gs', $face); + + $this->assertSame([], $mpdf->PDFAXwarnings); + $this->assertFalse($logger->hasWarningRecords(), 'nothing is left blank'); + } + /** * With compression on, a glyph's procedure is written deflated, and inflates to what it draws */ @@ -226,11 +297,7 @@ public function testAColourFontThatMayNotBeDrawnInColourDrawsNothingAndSaysSo(ar $mpdf->setLogger($logger); $mpdf->WriteHTML('

' . UtfString::code2utf(0x1F600) . '

'); - preg_match_all('/(?:^|\n)(\d+) 0 obj\n(.*?)\nendobj/s', $mpdf->OutputBinaryData(), $matches, PREG_SET_ORDER); - $objects = []; - foreach ($matches as $match) { - $objects[(int) $match[1]] = $match[2]; - } + $objects = $this->objectsOf($mpdf); $font = $this->objectMatching($objects, '/\/Subtype \/Type3/'); $procedures = $this->referenced($objects, $font, 'CharProcs'); diff --git a/tests/Mpdf/Fonts/Color/CbdtSourceTest.php b/tests/Mpdf/Fonts/Color/CbdtSourceTest.php index 320e9429b..36a9e1ab6 100644 --- a/tests/Mpdf/Fonts/Color/CbdtSourceTest.php +++ b/tests/Mpdf/Fonts/Color/CbdtSourceTest.php @@ -54,7 +54,7 @@ protected function set_up() $this->reader = $ttf->openFont(__DIR__ . '/../../../data/ttf/color/TestEmoji-CBDT.ttf'); $this->logger = new TestLogger(); - $this->source = new CbdtSource($ttf, $this->reader, 1000, $this->logger); + $this->source = new CbdtSource(new ColorFontFile($ttf, $this->reader, 1000, $this->logger)); $this->resources = new RecordingResources(); } @@ -360,7 +360,7 @@ private function source(array $tables, $cut = 0) { list($ttf, $reader) = $this->openFont(isset($tables['font']) ? $tables['font'] : $this->sfnt($tables, $cut)); - return new CbdtSource($ttf, $reader, 1000, $this->logger); + return new CbdtSource(new ColorFontFile($ttf, $reader, 1000, $this->logger)); } /** diff --git a/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php b/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php new file mode 100644 index 000000000..41d0250b5 --- /dev/null +++ b/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php @@ -0,0 +1,185 @@ +reader = $ttf->openFont(__DIR__ . '/../../../data/ttf/color/TestEmoji-COLRv0.ttf'); + $this->source = new ColrV0Source(new ColorFontFile($ttf, $this->reader, 1000, new NullLogger())); + } + + /** + * Closes the font files the test opened, and removes the fonts it built + */ + protected function tear_down() + { + $this->reader->close(); + $this->closeFonts(); + + parent::tear_down(); + } + + /** + * A coloured layer sets its colour inside q and Q, so the layer after it is drawn in whatever + * colour the text is + */ + public function testEachLayerIsFilledInItsColourAndAForegroundLayerInTheTexts() + { + $layers = preg_split('/(?<=f\n)/', $this->source->draw(12, new RecordingResources()), -1, PREG_SPLIT_NO_EMPTY); + + $this->assertCount(3, $layers); + $this->assertStringStartsWith("q 1.000 0.800 0.200 rg\n950 350 m\n", $layers[0], 'the face, yellow'); + $this->assertStringStartsWith("Q\nq 0.000 0.000 0.000 rg\n", $layers[1], 'the eyes, black'); + $this->assertStringStartsWith("Q\n300 250 m\n", $layers[2], 'the mouth, which sets no colour'); + } + + /** + * The heart's highlight is white at 0x80 + */ + public function testALayerLessThanOpaqueIsFilledThroughAGraphicsState() + { + $content = $this->source->draw(13, new RecordingResources()); + + $this->assertStringContainsString("q /GS0.50 gs 1.000 1.000 1.000 rg\n", $content, 'the highlight, white at 0x80'); + } + + /** + * A glyph COLR has no record of is left to the next source, its outline + */ + public function testAGlyphWithNoLayersIsNotDrawn() + { + $this->assertNull($this->source->draw(2, new RecordingResources()), 'the number sign'); + } + + /** + * A layer is filled in the colour its palette index names + */ + public function testASyntheticLayerIsFilledInItsColour() + { + $this->assertSame("q 1.000 0.000 0.000 rg\n0 0 m\n500 700 l\n1000 0 l\nh\nf\nQ\n", $this->synthetic($this->colr(), $this->cpal())->draw(1, new RecordingResources())); + } + + /** + * A layer naming a colour the palette does not hold is filled in the colour of the text, as one + * naming 0xFFFF is + * + * @dataProvider palettesWithoutTheColour + * + * @param string $colr The COLR table + * @param string $cpal The CPAL table + */ + public function testALayerWhoseColourIsNotThereIsInTheColourOfTheText($colr, $cpal) + { + $this->assertSame("0 0 m\n500 700 l\n1000 0 l\nh\nf\n", $this->synthetic($colr, $cpal)->draw(1, new RecordingResources())); + } + + /** + * @return array[] COLR and CPAL tables that leave glyph 1's layer no colour + */ + public function palettesWithoutTheColour() + { + return [ + 'the foreground' => [$this->colr(0xFFFF), $this->cpal()], + 'an index past the palette' => [$this->colr(1), $this->cpal()], + 'no palettes' => [$this->colr(), pack('n4N', 0, 1, 0, 1, 12) . "\0\0\xFF\xFF"], + 'a first palette past the colour records' => [$this->colr(), pack('n4Nn', 0, 1, 1, 1, 14, 1) . "\0\0\xFF\xFF"], + 'colour records past the end of the file' => [$this->colr(), pack('n4Nn', 0, 1, 1, 1, 0x7FFFFF00, 0)], + 'a CPAL cut short' => [$this->colr(), pack('n2', 0, 1)], + ]; + } + + /** + * A COLR table that runs short, or a colour glyph whose layers run past the ones it counts, draws + * nothing + * + * @dataProvider tablesNamingNoLayers + * + * @param string $colr The COLR table + */ + public function testATableNamingNoLayersDrawsNothing($colr) + { + $this->assertNull($this->synthetic($colr, $this->cpal())->draw(1, new RecordingResources())); + } + + /** + * @return array[] COLR tables that give glyph 1 no layers to draw + */ + public function tablesNamingNoLayers() + { + return [ + 'a COLR cut short' => [pack('n2', 0, 1)], + 'base records past the end of the file' => [pack('n2N2n', 0, 50000, 14, 14, 1)], + 'layers past the ones it counts' => [pack('n2N2n', 0, 1, 14, 20, 1) . pack('n3', 1, 0, 2) . pack('n2', 2, 0)], + 'layer records past the end of the file' => [pack('n2N2n', 0, 1, 14, 0x7FFFFF00, 1) . pack('n3', 1, 0, 1)], + ]; + } + + /** + * @param int $index The palette index glyph 1's one layer, glyph 2, is filled with + * + * @return string A COLR table of version 0 + */ + private function colr($index = 0) + { + // version, numBaseGlyphRecords, baseGlyphRecordsOffset, layerRecordsOffset, numLayerRecords + return pack('n2N2n', 0, 1, 14, 20, 1) . pack('n3', 1, 0, 1) . pack('n2', 2, $index); + } + + /** + * @return string A CPAL table of one palette of one colour, opaque red + */ + private function cpal() + { + // version, numPaletteEntries, numPalettes, numColorRecords, colorRecordsArrayOffset, then the + // palette's first colour record and the colour, blue, green, red, alpha + return pack('n4Nn', 0, 1, 1, 1, 14, 0) . "\0\0\xFF\xFF"; + } + + /** + * @param string $colr The COLR table + * @param string $cpal The CPAL table + * + * @return ColrV0Source Over a font of glyphs 0 to 2, glyph 2 the triangle, and those tables + */ + private function synthetic($colr, $cpal) + { + $tables = $this->glyphTables(['', '', $this->triangle()]) + ['COLR' => $colr, 'CPAL' => $cpal]; + list($ttf, $reader) = $this->openFont($this->sfnt($tables)); + + return new ColrV0Source(new ColorFontFile($ttf, $reader, 1000, new NullLogger())); + } +} diff --git a/tests/Mpdf/Fonts/Color/RecordingResources.php b/tests/Mpdf/Fonts/Color/RecordingResources.php index 06ae44894..4af651b79 100644 --- a/tests/Mpdf/Fonts/Color/RecordingResources.php +++ b/tests/Mpdf/Fonts/Color/RecordingResources.php @@ -31,4 +31,12 @@ public function image($data) return ['/I' . count($this->images), 64, 64]; } + + /** + * @inheritdoc + */ + public function alpha($opacity) + { + return sprintf('/GS%.2F gs', $opacity); + } } diff --git a/tests/Mpdf/Fonts/Color/SbixSourceTest.php b/tests/Mpdf/Fonts/Color/SbixSourceTest.php index 842e87259..2113ce69f 100644 --- a/tests/Mpdf/Fonts/Color/SbixSourceTest.php +++ b/tests/Mpdf/Fonts/Color/SbixSourceTest.php @@ -54,7 +54,7 @@ protected function set_up() $this->reader = $ttf->openFont(__DIR__ . '/../../../data/ttf/color/TestEmoji-sbix.ttf'); $this->logger = new TestLogger(); - $this->source = new SbixSource($ttf, $this->reader, 1000, $this->logger); + $this->source = new SbixSource(new ColorFontFile($ttf, $this->reader, 1000, $this->logger)); $this->resources = new RecordingResources(); } @@ -255,6 +255,6 @@ private function synthetic($sbix, $numGlyphs = 4, $cut = 0) { list($ttf, $reader) = $this->openFont($this->sfnt(['maxp' => pack('Nn', 0x00005000, $numGlyphs), 'sbix' => $sbix], $cut)); - return new SbixSource($ttf, $reader, 1000, $this->logger); + return new SbixSource(new ColorFontFile($ttf, $reader, 1000, $this->logger)); } } diff --git a/tests/Mpdf/Fonts/Color/SyntheticFonts.php b/tests/Mpdf/Fonts/Color/SyntheticFonts.php index cd8c9b775..bb9985d34 100644 --- a/tests/Mpdf/Fonts/Color/SyntheticFonts.php +++ b/tests/Mpdf/Fonts/Color/SyntheticFonts.php @@ -44,6 +44,35 @@ private function sfnt(array $tables, $cut = 0) return substr($font, 0, strlen($font) - $cut); } + /** + * @param string[] $glyphs Each glyph's glyf data, by glyph id + * + * @return string[] head, maxp, loca and glyf for those glyphs, loca in its long format + */ + private function glyphTables(array $glyphs) + { + $loca = pack('N', 0); + $glyf = ''; + foreach ($glyphs as $glyph) { + $glyf .= $glyph; + $loca .= pack('N', strlen($glyf)); + } + + // unitsPerEm at 18, indexToLocFormat at 50 + $head = str_repeat("\0", 18) . pack('n', 1000) . str_repeat("\0", 30) . pack('n2', 1, 0); + + return ['head' => $head, 'maxp' => pack('Nn', 0x00005000, count($glyphs)), 'loca' => $loca, 'glyf' => $glyf]; + } + + /** + * @return string A simple glyph of one contour: the triangle 0,0 500,700 1000,0, each point on the + * curve and each coordinate a two-byte delta + */ + private function triangle() + { + return pack('n5', 1, 0, 0, 1000, 700) . pack('n2', 2, 0) . "\1\1\1" . pack('n3', 0, 500, 500) . pack('n3', 0, 700, 0x10000 - 700); + } + /** * Writes a font and opens it * diff --git a/tests/Mpdf/Fonts/ColorFormatsTest.php b/tests/Mpdf/Fonts/ColorFormatsTest.php index 7849bcccb..7111ccae8 100644 --- a/tests/Mpdf/Fonts/ColorFormatsTest.php +++ b/tests/Mpdf/Fonts/ColorFormatsTest.php @@ -4,6 +4,7 @@ use Mpdf\Cache; use Mpdf\Fonts\Color\ColorFormats; +use Mpdf\Mpdf; use Mpdf\TTFontFile; /** @@ -107,24 +108,68 @@ private function read($name) public function testAFontIsDrawnInTheFirstFormatItCarriesThatMpdfDraws() { $this->assertSame('CBDT', ColorFormats::choose(['COLRv1', 'CBDT', 'sbix'], true)); - $this->assertSame('', ColorFormats::choose(['COLRv0'], true), 'a format mPDF does not draw yet is not chosen'); + $this->assertSame('', ColorFormats::choose(['SVG'], true), 'a format mPDF does not draw yet is not chosen'); + $this->assertSame('COLRv0', ColorFormats::choose(['COLRv1', 'COLRv0', 'CBDT'], true), 'a COLR version 1 font is drawn from its version 0 records, ahead of its bitmaps'); $this->assertSame('', ColorFormats::choose(['CBDT'], false), 'nor anything, where colour is off'); } /** * A font in a format mPDF draws is written as Type3 fonts, where the fixture's layer glyphs, from 27 - * on, are drawn only inside the emoji that use them. A font mPDF would draw as TrueType hands every - * glyph a code, as it always has. + * on, are drawn only inside the emoji that use them. A font mPDF draws as TrueType hands every glyph + * a code, as it always has: Noto Emoji's family ligature, glyph 1483, is one no character maps to. */ public function testAFontWrittenAsType3GivesCodesOnlyToTheGlyphsTextCanReach() { - $type3 = $this->read('TestEmoji-CBDT.ttf'); - $trueType = $this->read('TestEmoji-COLRv0.ttf'); + $type3 = $this->read('TestEmoji-COLRv0.ttf'); $this->assertTrue(isset($type3->glyphToChar[26]), 'a ligature is reached through GSUB'); $this->assertTrue(isset($type3->glyphToChar[9]), 'a tag is reached through the tags'); $this->assertFalse(isset($type3->glyphToChar[27]), 'a layer is reached by neither'); - $this->assertTrue(isset($trueType->glyphToChar[27])); + + $trueType = new TTFontFile(new FontCache(new Cache(__DIR__ . '/../tmp/mpdf/ttfontdata')), 'win'); + $trueType->getMetrics(__DIR__ . '/../../../packages/Emoji/fonts/NotoEmoji-Regular.ttf', uniqid('', true), 0, false, false, 0xFF); + $this->assertTrue(isset($trueType->glyphToChar[1483])); + } + + /** + * A font of bitmaps can still have a glyf table of empty glyphs, as the sbix fixture does + */ + public function testAFontHasOutlinesOnlyWhereAGlyphHasOne() + { + $this->assertTrue($this->read('TestEmoji-COLRv0.ttf')->hasOutlines); + $this->assertFalse($this->read('TestEmoji-sbix.ttf')->hasOutlines); + $this->assertFalse($this->read('TestEmoji-CBDT.ttf')->hasOutlines); + } + + /** + * A glyph is drawn by the font's colour format, then by its outline, and by its outline alone + * where colour is off + */ + public function testAFontIsDrawnByItsColourFormatThenItsOutlines() + { + $mpdf = new Mpdf(['mode' => 'utf-8']); + $colr = ['colorFormats' => ['COLRv1', 'COLRv0'], 'hasOutlines' => true]; + + $this->assertSame(['Mpdf\Fonts\Color\ColrV0Source', 'Mpdf\Fonts\Color\OutlineSource'], ColorFormats::sources($colr, $mpdf)); + $this->assertSame(['Mpdf\Fonts\Color\CbdtSource'], ColorFormats::sources(['colorFormats' => ['CBDT'], 'hasOutlines' => false], $mpdf)); + + $mpdf->PDFA = true; + $this->assertSame(['Mpdf\Fonts\Color\OutlineSource'], ColorFormats::sources($colr, $mpdf)); + } + + /** + * A font draws nothing where colour is off only if it has no outlines to draw from instead + */ + public function testOnlyAColourFontWithoutOutlinesIsBlankWhereColourIsOff() + { + $mpdf = new Mpdf(['mode' => 'utf-8', 'restrictColorSpace' => 1]); + + $this->assertTrue(ColorFormats::blank(['colorFormats' => ['CBDT'], 'hasOutlines' => false], $mpdf)); + $this->assertFalse(ColorFormats::blank(['colorFormats' => ['COLRv0'], 'hasOutlines' => true], $mpdf)); + $this->assertFalse(ColorFormats::blank(['colorFormats' => ['SVG'], 'hasOutlines' => false], $mpdf), 'a format mPDF does not draw is not written as Type3'); + + $mpdf->restrictColorSpace = 0; + $this->assertFalse(ColorFormats::blank(['colorFormats' => ['CBDT'], 'hasOutlines' => false], $mpdf), 'nor with colour on'); } /** diff --git a/tests/Mpdf/Fonts/GlyphOutlineTest.php b/tests/Mpdf/Fonts/GlyphOutlineTest.php new file mode 100644 index 000000000..95421511b --- /dev/null +++ b/tests/Mpdf/Fonts/GlyphOutlineTest.php @@ -0,0 +1,136 @@ +closeFonts(); + + parent::tear_down(); + } + + /** + * @dataProvider glyphs + * + * @param string $file A font under tests/data + * @param int $glyph The glyph id + * @param string $expected The path fontTools draws for it + */ + public function testAGlyphIsDrawnAsFontToolsDrawsIt($file, $glyph, $expected) + { + $ttf = new TTFontFile(new FontCache(new Cache(__DIR__ . '/../tmp/mpdf/ttfontdata')), 'win'); + $reader = $ttf->openFont(__DIR__ . '/../../data/' . $file); + + try { + $path = (new GlyphOutline($ttf, $reader, new TestLogger()))->path($glyph); + } finally { + $reader->close(); + } + + $actual = preg_split('/\s+/', trim($path)); + $expected = preg_split('/\s+/', trim($expected)); + $this->assertCount(count($expected), $actual, $path); + + // The cubic's control points are worked out in a different order from fontTools', so the + // third place can round the other way + foreach ($expected as $i => $token) { + if (is_numeric($token)) { + $this->assertEqualsWithDelta((float) $token, (float) $actual[$i], 0.0015, sprintf('token %d of %s', $i, $path)); + } else { + $this->assertSame($token, $actual[$i], sprintf('token %d of %s', $i, $path)); + } + } + } + + /** + * @return array[] Each font, glyph and path in tests/data/glyphoutline/paths.json + */ + public function glyphs() + { + $cases = []; + foreach (json_decode(file_get_contents(__DIR__ . '/../../data/glyphoutline/paths.json'), true) as $name => $case) { + list($file) = explode(' ', $name); + $cases[$name] = [$file, $case['glyph'], $case['path']]; + } + + return $cases; + } + + /** + * The triangle the malformed glyphs below are cut from draws as itself, and so does a composite + * moving it + */ + public function testASyntheticGlyphIsDrawn() + { + $composite = pack('n5', 0xFFFF, 0, 0, 1000, 700) . pack('n2', GlyphOperator::WORDS | GlyphOperator::XY_VALUES, 0) . pack('n2', 10, 20); + + $outline = $this->outline([$this->triangle(), $composite]); + + $this->assertSame("0 0 m\n500 700 l\n1000 0 l\nh\n", $outline->path(0)); + $this->assertSame("10 20 m\n510 720 l\n1010 20 l\nh\n", $outline->path(1)); + } + + /** + * A glyph that runs short of what it says it holds draws nothing and warns of nothing, rather than + * reading past its end + * + * @dataProvider malformed + * + * @param string $glyph The glyph's glyf data + */ + public function testAGlyphThatRunsShortDrawsNothing($glyph) + { + $this->assertSame('', $this->outline([$glyph])->path(0)); + } + + /** + * @return array[] Glyphs that run short of what they say they hold + */ + public function malformed() + { + $triangle = $this->triangle(); + $header = pack('n5', 1, 0, 0, 1000, 700); + + return [ + 'shorter than its header' => [substr($triangle, 0, 6)], + 'more contours than it holds' => [pack('n5', 200, 0, 0, 1000, 700) . pack('n2', 2, 0)], + 'instructions longer than the glyph' => [$header . pack('n2', 2, 500) . "\1\1\1"], + 'fewer flags than points' => [$header . pack('n2', 2, 0) . "\1"], + 'a flag repeated past the end' => [$header . pack('n2', 2, 0) . "\x09"], + 'coordinates cut short' => [substr($triangle, 0, -3)], + 'contours ending before the last' => [pack('n5', 2, 0, 0, 1000, 700) . pack('n3', 2, 1, 0) . "\1\1\1" . pack('n6', 0, 500, 500, 0, 700, 0x10000 - 700)], + 'a component cut short' => [pack('n5', 0xFFFF, 0, 0, 1000, 700) . pack('n2', GlyphOperator::WORDS | GlyphOperator::XY_VALUES, 0) . pack('n', 10)], + 'a component of itself' => [pack('n5', 0xFFFF, 0, 0, 1000, 700) . pack('n2', GlyphOperator::XY_VALUES, 0) . "\0\0"], + ]; + } + + /** + * @param string[] $glyphs Each glyph's glyf data, by glyph id + * + * @return GlyphOutline Over a font of those glyphs + */ + private function outline(array $glyphs) + { + list($ttf, $reader) = $this->openFont($this->sfnt($this->glyphTables($glyphs))); + + return new GlyphOutline($ttf, $reader, new TestLogger()); + } +} diff --git a/tests/Mpdf/PresentationSelectorTest.php b/tests/Mpdf/PresentationSelectorTest.php new file mode 100644 index 000000000..6c75ba72c --- /dev/null +++ b/tests/Mpdf/PresentationSelectorTest.php @@ -0,0 +1,72 @@ + 'utf-8', + 'fontRegistry' => new FontRegistry([]), + 'fontDir' => [__DIR__ . '/../data/ttf/color'], + 'fontdata' => ['emoji' => ['R' => $font, 'useOTL' => 0xFF]], + 'default_font' => 'emoji', + ]); + $mpdf->WriteHTML('

' . implode('', array_map('Mpdf\Utils\UtfString::code2utf', $codepoints)) . '

'); + + return $mpdf->drawnCodepoints(0); + } + + /** + * The keycap forms from the fully qualified sequence in either font + * + * @dataProvider fonts + * + * @param string $font A font in tests/data/ttf/color + */ + public function testAKeycapFormsWhetherTheFontsSequenceHasTheSelectorOrNot($font) + { + $drawn = $this->drawn($font, [0x31, 0xFE0F, 0x20E3]); + + $this->assertCount(1, $drawn); + $this->assertGreaterThanOrEqual(0xE000, $drawn[0]); + } + + /** + * Twemoji gives U+FE0F a full em of advance, so one no ligature takes in is taken out after + * + * @dataProvider fonts + * + * @param string $font A font in tests/data/ttf/color + */ + public function testASelectorNoLigatureTakesInIsNotDrawn($font) + { + $this->assertSame([0x2764], $this->drawn($font, [0x2764, 0xFE0F])); + } + + /** + * @return array[] Each font, as its keycap is formed + */ + public function fonts() + { + return [ + 'Noto\'s sequences' => ['TestEmoji-COLRv0.ttf'], + 'Twemoji\'s sequences' => ['TestEmoji-FE0F.ttf'], + ]; + } +} diff --git a/tests/Snapshots/ColorEmojiColrV0SnapshotTest.php b/tests/Snapshots/ColorEmojiColrV0SnapshotTest.php new file mode 100644 index 000000000..888995f28 --- /dev/null +++ b/tests/Snapshots/ColorEmojiColrV0SnapshotTest.php @@ -0,0 +1,51 @@ +mpdf and + * loading it with content + * + * @return void + * @internal Don't call any $this->mpdf->Output*() method + */ + public function generatePdf() + { + $this->mpdf = $this->createMpdf([ + 'fontDir' => [__DIR__ . '/../data/ttf/color'], + 'fontdata' => ['colr' => ['R' => 'TestEmoji-COLRv0.ttf', 'useOTL' => 0xFF]], + 'default_font' => 'dejavusans', + 'useSubstitutions' => true, + 'backupSubsFont' => ['colr'], + ]); + + $emoji = '😀 ❤️ 👨‍👩‍👧 🇦🇺 ' + . '1️⃣ 👍🏽 🏴󠁧󠁢󠁥󠁮󠁧󠁿'; + + $this->mpdf->WriteHTML( + '

Emoji ' . $emoji . ' end

' + . '

' . str_repeat('Text with emoji ' . $emoji . ' in it. ', 5) . '

' + . '

Red text keeps its emoji in colour: ' . $emoji . '

' + . '

## 😀

' + ); + } +} diff --git a/tests/data/glyphoutline/build.py b/tests/data/glyphoutline/build.py new file mode 100644 index 000000000..778bf3fd5 --- /dev/null +++ b/tests/data/glyphoutline/build.py @@ -0,0 +1,68 @@ +""" +Writes the paths GlyphOutlineTest expects, as fontTools draws them: + + python3 tests/data/glyphoutline/build.py + +fontTools' BasePen splits a TrueType contour into quadratic segments at its implied on-curve points +and draws each as the cubic with control points two thirds of the way to the quadratic's, which is +what a PDF path needs; composites are drawn through their transforms. Each glyph's path is written +as the PDF operators GlyphOutline writes, to three places. +""" + +import json +import os + +from fontTools.pens.basePen import BasePen +from fontTools.ttLib import TTFont + +HERE = os.path.dirname(os.path.abspath(__file__)) +DATA = os.path.join(HERE, '..') + +GLYPHS = { + 'ttf/NotoSans-Regular.ttf': ['O', 'a', 'i', 'percent', 'Aacute', 'uni01C4', 'space'], + 'ttf/color/TestEmoji-COLRv0.ttf': ['layer.face', 'layer.frame', 'layer.small.left', 'one'], +} + + +def number(value): + text = ('%.3f' % value).rstrip('0').rstrip('.') + return '0' if text == '-0' else text + + +class PdfPen(BasePen): + def __init__(self, glyphSet): + BasePen.__init__(self, glyphSet) + self.ops = [] + + def _moveTo(self, pt): + self.ops.append('%s %s m' % (number(pt[0]), number(pt[1]))) + + def _lineTo(self, pt): + self.ops.append('%s %s l' % (number(pt[0]), number(pt[1]))) + + def _curveToOne(self, p1, p2, p3): + self.ops.append(' '.join(number(v) for v in (p1[0], p1[1], p2[0], p2[1], p3[0], p3[1])) + ' c') + + def _closePath(self): + # BasePen draws the closing segment back to the start as a line where it is straight; a PDF + # path closes with h either way + if self.ops and self.ops[-1].endswith(' l'): + start = self.ops[[i for i, op in enumerate(self.ops) if op.endswith(' m')][-1]] + if self.ops[-1][:-2] == start[:-2]: + self.ops.pop() + self.ops.append('h') + + +expected = {} +for path, names in GLYPHS.items(): + font = TTFont(os.path.join(DATA, path)) + glyphSet = font.getGlyphSet() + order = font.getGlyphOrder() + for name in names: + pen = PdfPen(glyphSet) + glyphSet[name].draw(pen) + expected[path + ' ' + name] = {'glyph': order.index(name), 'path': '\n'.join(pen.ops) + ('\n' if pen.ops else '')} + +with open(os.path.join(HERE, 'paths.json'), 'w') as f: + json.dump(expected, f, indent=1, sort_keys=True) + f.write('\n') diff --git a/tests/data/glyphoutline/paths.json b/tests/data/glyphoutline/paths.json new file mode 100644 index 000000000..406ef1dc9 --- /dev/null +++ b/tests/data/glyphoutline/paths.json @@ -0,0 +1,46 @@ +{ + "ttf/NotoSans-Regular.ttf Aacute": { + "glyph": 131, + "path": "545 0 m\n459 221 l\n176 221 l\n91 0 l\n0 0 l\n279 717 l\n360 717 l\n638 0 l\nh\n352 517 m\n350 522.333 346.667 532 342 546 c\n337.333 560 332.833 574.5 328.5 589.5 c\n324.167 604.5 320.667 616 318 624 c\n313.333 603.333 308 583.167 302 563.5 c\n296 543.833 291 528.333 287 517 c\n206 301 l\n432 301 l\nh\n466 934 m\n458 922 445.667 906.5 429 887.5 c\n412.333 868.5 394.5 849.5 375.5 830.5 c\n356.5 811.5 339 796 323 784 c\n265 784 l\n265 796 l\n275 808.667 285.833 823.667 297.5 841 c\n309.167 858.333 320.667 876.167 332 894.5 c\n343.333 912.833 352.667 929.333 360 944 c\n466 944 l\nh\n" + }, + "ttf/NotoSans-Regular.ttf O": { + "glyph": 50, + "path": "720 358 m\n720 284 707.5 219.5 682.5 164.5 c\n657.5 109.5 620.667 66.667 572 36 c\n523.333 5.333 463 -10 391 -10 c\n317 -10 255.5 5.333 206.5 36 c\n157.5 66.667 121 109.667 97 165 c\n73 220.333 61 285 61 359 c\n61 432.333 73 496.333 97 551 c\n121 605.667 157.5 648.333 206.5 679 c\n255.5 709.667 317.333 725 392 725 c\n463.333 725 523.333 709.833 572 679.5 c\n620.667 649.167 657.5 606.5 682.5 551.5 c\n707.5 496.5 720 432 720 358 c\nh\n156 358 m\n156 268 175 197.167 213 145.5 c\n251 93.833 310.333 68 391 68 c\n472.333 68 531.667 93.833 569 145.5 c\n606.333 197.167 625 268 625 358 c\n625 448 606.333 518.5 569 569.5 c\n531.667 620.5 472.667 646 392 646 c\n311.333 646 251.833 620.5 213.5 569.5 c\n175.167 518.5 156 448 156 358 c\nh\n" + }, + "ttf/NotoSans-Regular.ttf a": { + "glyph": 68, + "path": "288 545 m\n353.333 545 401.667 530.667 433 502 c\n464.333 473.333 480 427.667 480 365 c\n480 0 l\n416 0 l\n399 76 l\n395 76 l\n371.667 46.667 347.167 25 321.5 11 c\n295.833 -3 260.333 -10 215 -10 c\n166.333 -10 126 2.833 94 28.5 c\n62 54.167 46 94.333 46 149 c\n46 202.333 67 243.5 109 272.5 c\n151 301.5 215.667 317.333 303 320 c\n394 323 l\n394 355 l\n394 399.667 384.333 430.667 365 448 c\n345.667 465.333 318.333 474 283 474 c\n255 474 228.333 469.833 203 461.5 c\n177.667 453.167 154 443.667 132 433 c\n105 499 l\n128.333 511.667 156 522.5 188 531.5 c\n220 540.5 253.333 545 288 545 c\nh\n314 259 m\n247.333 256.333 201.167 245.667 175.5 227 c\n149.833 208.333 137 182 137 148 c\n137 118 146.167 96 164.5 82 c\n182.833 68 206.333 61 235 61 c\n280.333 61 318 73.5 348 98.5 c\n378 123.5 393 162 393 214 c\n393 262 l\nh\n" + }, + "ttf/NotoSans-Regular.ttf i": { + "glyph": 76, + "path": "130 737 m\n143.333 737 155.167 732.5 165.5 723.5 c\n175.833 714.5 181 700.333 181 681 c\n181 662.333 175.833 648.333 165.5 639 c\n155.167 629.667 143.333 625 130 625 c\n115.333 625 103 629.667 93 639 c\n83 648.333 78 662.333 78 681 c\n78 700.333 83 714.5 93 723.5 c\n103 732.5 115.333 737 130 737 c\nh\n173 536 m\n173 0 l\n85 0 l\n85 536 l\nh\n" + }, + "ttf/NotoSans-Regular.ttf percent": { + "glyph": 8, + "path": "195 724 m\n244.333 724 281.667 704.5 307 665.5 c\n332.333 626.5 345 571.667 345 501 c\n345 430.333 332.833 375.167 308.5 335.5 c\n284.167 295.833 246.333 276 195 276 c\n147.667 276 111.5 295.833 86.5 335.5 c\n61.5 375.167 49 430.333 49 501 c\n49 571.667 60.667 626.5 84 665.5 c\n107.333 704.5 144.333 724 195 724 c\nh\n652 714 m\n256 0 l\n179 0 l\n575 714 l\nh\n195 662 m\n169.667 662 151.167 648.5 139.5 621.5 c\n127.833 594.5 122 554.333 122 501 c\n122 447.667 127.833 407.333 139.5 380 c\n151.167 352.667 169.667 339 195 339 c\n246.333 339 272 393 272 501 c\n272 608.333 246.333 662 195 662 c\nh\n632 438 m\n680.667 438 717.833 418.5 743.5 379.5 c\n769.167 340.5 782 285.667 782 215 c\n782 144.333 769.833 89.167 745.5 49.5 c\n721.167 9.833 683.333 -10 632 -10 c\n584.667 -10 548.5 9.833 523.5 49.5 c\n498.5 89.167 486 144.333 486 215 c\n486 285.667 497.667 340.5 521 379.5 c\n544.333 418.5 581.333 438 632 438 c\nh\n632 375 m\n606.667 375 588.167 361.667 576.5 335 c\n564.833 308.333 559 268.333 559 215 c\n559 161 564.833 120.5 576.5 93.5 c\n588.167 66.5 606.667 53 632 53 c\n683.333 53 709 107 709 215 c\n709 321.667 683.333 375 632 375 c\nh\n" + }, + "ttf/NotoSans-Regular.ttf space": { + "glyph": 3, + "path": "" + }, + "ttf/NotoSans-Regular.ttf uni01C4": { + "glyph": 911, + "path": "669 364 m\n669 243.333 636.167 152.5 570.5 91.5 c\n504.833 30.5 413.333 0 296 0 c\n97 0 l\n97 714 l\n317 714 l\n389 714 451.333 700.667 504 674 c\n556.667 647.333 597.333 608.167 626 556.5 c\n654.667 504.833 669 440.667 669 364 c\nh\n574 361 m\n574 456.333 550.5 526.167 503.5 570.5 c\n456.5 614.833 390 637 304 637 c\n187 637 l\n187 77 l\n284 77 l\n477.333 77 574 171.667 574 361 c\nh\n1253 0 m\n758 0 l\n758 68 l\n1134 634 l\n770 634 l\n770 714 l\n1243 714 l\n1243 646 l\n867 80 l\n1253 80 l\nh\n964 784 m\n955.333 799.333 943.667 815.833 929 833.5 c\n914.333 851.167 899 868.667 883 886 c\n867 903.333 853 918.333 841 931 c\n841 944 l\n901 944 l\n918.333 932.667 936.333 919 955 903 c\n973.667 887 991.333 870 1008 852 c\n1026 870 1044.333 887 1063 903 c\n1081.667 919 1099.667 932.667 1117 944 c\n1179 944 l\n1179 931 l\n1166.333 918.333 1151.833 903.333 1135.5 886 c\n1119.167 868.667 1103.5 851.167 1088.5 833.5 c\n1073.5 815.833 1062 799.333 1054 784 c\nh\n" + }, + "ttf/color/TestEmoji-COLRv0.ttf layer.face": { + "glyph": 29, + "path": "950 350 m\n950 474 906 580 818 668 c\n730 756 624 800 500 800 c\n376 800 270 756 182 668 c\n94 580 50 474 50 350 c\n50 226 94 120 182 32 c\n270 -56 376 -100 500 -100 c\n624 -100 730 -56 818 32 c\n906 120 950 226 950 350 c\nh\n" + }, + "ttf/color/TestEmoji-COLRv0.ttf layer.frame": { + "glyph": 30, + "path": "50 -100 m\n50 800 l\n950 800 l\n950 -100 l\nh\n150 0 m\n150 700 l\n850 700 l\n850 0 l\nh\n" + }, + "ttf/color/TestEmoji-COLRv0.ttf layer.small.left": { + "glyph": 38, + "path": "380.023 440.009 m\n380.023 489.612 362.422 532.014 327.22 567.216 c\n292.018 602.418 249.615 620.02 200.012 620.02 c\n150.409 620.02 108.007 602.418 72.804 567.216 c\n37.602 532.014 20.001 489.612 20.001 440.009 c\n20.001 390.406 37.602 348.003 72.804 312.801 c\n108.007 277.599 150.409 259.998 200.012 259.998 c\n249.615 259.998 292.018 277.599 327.22 312.801 c\n362.422 348.003 380.023 390.406 380.023 440.009 c\nh\n" + }, + "ttf/color/TestEmoji-COLRv0.ttf one": { + "glyph": 3, + "path": "250 0 m\n250 600 l\n150 500 l\n150 600 l\n300 700 l\n350 700 l\n350 0 l\nh\n" + } +} diff --git a/tests/data/snapshots/color-emoji-colrv0.pdf b/tests/data/snapshots/color-emoji-colrv0.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f6aa10fb2a6ef550e774634acfead68004f5dbc1 GIT binary patch literal 30328 zcmdqH1yo&GwkV22a0r3m2MZ9~4<6jz-Q6uX1c%`6?(Xgo9D+m8;2PXD?~tmluByJb z`~C6X81Ib>S$i*?Yx- z1P^-J8(BL77(lD?@N`l}hGu&FHZA}Sde8(5I}0r%kRAYJW}pSK(`&)gDcYM^{kBX? zPY>Gi>-n$SJ1Au|y7s86h03k*A-})w?Jujq)z|)B+FaS&( z{y0MqP_&2F&;ng!_{ZAsa2SDqf@AkP8%zM8t%~*l&>m(+S{6on02?cimXQrKX$SyN zfC+(Ff!TnWgPDN=C;^J*AQS*{zaz#B_)icS7};s**;)Srk_$-BNDpKHHAYZl0ySn( z16>ScWu)i%+ZF%u1|}dANXUP^fdb43%o@z_pXmOBNBj&{ld{ z24*IJqVvn}-*Ah8m6n}>?H9NH0H8qurU*j91?{?)Mma^HVr*nimrLh--F z>BX?wSZP`5LEiAsNc~MUn0}cx%P*_`qYtv_Kl&hx{-Y1F=Rf)&YyP7TvgP0Vf4Ahn zp!+XhuV1d}FI)Z{MK7L;jg6Lxo%yd6{XWyrse|76Gi>Vtou`M)D5 zMs{Y9hXN`2{}@Rzfx<2pHjvN#Ge!RcB*ntONXyRpHx2yXwW)t=9U!OiN38K5{R}IR zjh2lO6pw(SGOqs^HvJ`J{4atRc6JajdcdE7{`x9{0siqZ{70zwr}*fXHM9OLh6Mf* zQ2!Q@+W%`n$;tu>rC8X14=Ck9uF(*5kK=Cv5~ybZ>KTFAf;s$t`^);=33f>|s;P?XLB{7ue`OrT$SP&D>OSp1JGUh;|GQ-8ZUX2JOGpn3D`KjBu@V^Y5Hf%!t!^(LN1OX3XXb?pgfFU3P2}qV{fHr z2~TIB_X1Xc5A^)!Vb;GN76v6o@N^1J`i{TSxffLFL2&dOj9yj%bW))FkB^T^(8yd* z*+~JEWx>-4SsU0Gnpv9w=)?>`IhC2CD~%{TDB*H20Fka|?f7yKL?YV@i7(lnjgqw) z=!6jf_%ib+WL|dsC7JviG6G_PFEcMVG5!afKwA}D9UP6U#H@{N0PMdmqI)^~f|k9R zt)q=SfZ-(@1*NBgDgah`5RobX4FD74%Yy+3V5VmPFtdWPR3;#Rm5CL=&H~E3Kw0Zc z9M8l~|EsZpRODs-Wj_-u!*6dUHjo$r%s>DO+pmKlT>;q^6KEO)jg|3tfS_Agm_VuH zi$$;knL!Pt>+C=P8z`}31tGx#x`yqgu`mJ{*;#%yR**fgfS>|dL0%km0UPiICaC}N z2JK;G0yPE(02}E13ruz(JLvt}+{^nH+8{h$-Y;msp#RcX8JK>ZWdV`+8w}81(7nI< zOdvC11O93(Z0x_@>}()>LG!E(|JcIKJz{8)eE z1L*y7KZs2)3NI$8Xd@yfD5Yl$$}0Y*5ic6@w{)DD^`F`J%V9BVGmu)!+S?c?7&!ur z&8!XWf3Fh&^o>l+tl>de8k!k6{yzFOXJDmg`|IX^>c~q4LmsprWVEgT3O-OVz=#r_ zPR8EQ$o@sODU<{hKw||bTU$#btC!jaJxFl@hDOF9I3U!e^{ha}k6*`V{;&ejnSbv1 zW%M9aKqnY}!{x;WKt&1zBT%sdR5alN=>o`;a0!Ece$^xXw*LDmedGUPJK(p&FJ9*# z>!9v0GXKENz{&olDD#UKzi@uR#mw614|dtw*uDVx)&A9gz1Yz2qWJ&Jrx$21XMb7T zzgYM`O%nc>iT=jRe|0pVLXMuTsF9h8sUv`mksTgDr~3Pdg_#XBdNFVQUv(rJ26~V# z(l9fy02o+-FBKYAHjuW_30vx!fJ#1~9^Z>|as;q50zre{T$Lk$hLMRCG?X;b`{mB) zL47etJxenKK5G+8Bap-abP6Dsq6`4OTqkAb-~dt$6*EInmFUHRysWx{_PrFOWc93V z96((V(?C80pbr44KIj+ZS3o0105cD$4D}ne!e*96i~x|5{?!-$GKIgXK0Pz&h?%9M zk^L`H9E}8xUUcNegZ}=laY;KG-&KYK1N$_2lJ-N+*pSTKQ1QLLB~9F5@ZOV7PS9Sa z!wK&4V!qFHU8CpG$p=`@ne_rc+7uw91hWUV|&8%P*gZENgyIX65^FV zi6Gp%EVBSIy1(RTH{}Rf2pkR?Iusf)1O&0LAp>C5g}C();<#q51^#mdnp)?Nk|^e% zDzi-Ns9Gv3n>TllOxKRiPP3)Xohp?UsCxcU*fe}lZ^BZe;u!N~oCri=TyxXz&4;=T~eUixT{zl9Lc{659sKC+cwpyLmq0{s9>XY|4wM_QI z=Kcwd0L7uQ%k#bEO&k1k<3>7Mc(9Lq{xWwfP=8MK6EDPRt60gYqLPl*=K^BKo45CE zIWF{__5=OkQBGY}4Oce!}jlXS#!Yzy1Tx4kj8kWEc3%^}h3-bH+<4 zf--nFT7Q&gh?<`^PqY0!ha&81QEK=&Ccxv$uT=DbWYwYQh6AI6fwR{ZcyXG2)7c^} z0+v>4vcTQ*B#XcSpP4vmUH9tbQ!Bb@$7}VJCQno75}yQ>Gr6{yLr5#m2_Z*3x4G11bu}o}R9PB3>*c`*# zPBf)6QDBHso_%{ZQdLlMLG20sln8K$2WwJ4i5g^?2V`M^Yg8N#d$%aP-T?#O*n-eXDUT5>=qpi3k+ZHf>zJ~?GCwxt5lot*_GgV%El!RMe?EO6@cKrs=Xh+G-^}gM zgk^5rZEKoJk4?Le&n)1+V|yJYy-59~$9n;KOkA(nzVd2y&*w%x6jr|R6Ofb$*)ge0 zDP(al5pHQsJ;+x}_!%C(QddlM^o(MEFk}ul>Pql1WX_ORBaeo43RA5ox8Xhd)OGb@ z5tH|YQvTaW;Di?#X&Tg9x0!LH$?WY%dFiQFNuTD1<{Bo)0^5dQ(OT}O!WCc|3-a<; zKE-^kZ;uT_0r=?;vP79x7k@R?CUE&DonU8_Q-mi8f`e`Lwh|h7=7adugT{BRyI8XWg3C+T?K8|_(q+O ze^4M4>@6q0*T>XZOU47TObDnEpB#NFck)kDz}qit-By14H_>r(adC!;W1o=Hx=rcX zZ*?`QR?vvn&44k2IJS8@??W7gYe*I&@t=qnx?zh>cGthH=K~AM%wRg?JRP|Ra(FZs znFYAHHz(z z?7q3Ltjp29fnOUU^=8+^BD9397E!x`I&>3#$0u1(Goj#$poo~88V5?NBjQ~MutEojTt&9w<@tcJmw4chOU}I5Q$0;|5Ilxx@)K+c)n(OhNymT0i??=5 zYy83HX+J&3%$;1){bH2f&@n}Z>0CFnt&$vi9 z*Af-tO6joGPP6$^OgLCvDJJq=tTe@eh2cVJ+WZ#EEY8Y|**?D@rYtaw@Qtq2O2@0n z>Mz&9M|vTlAtN79aC!+P+vrZa-=6VpdUF3Lb?-0R$Hl@%FC~9hsAf|mnBtZyAen%R z=*hAp=hqxykKBQGIU?C98RpF z#-HRV(!4j1nbL$jj>5I;`6}b^?@z@dW6l^-Lg&~6VLTI@KH{vI4QR5G4Rje)m5lzA)aL8 z<+$rs32bS)zqTpBD{s?K^3KoD;xt0Pe&~%sK>&TH+OF!8k|4#{T;3Ia{S=0%4}D#w z%Fso&**Yno0rTpagU>f0SQ!rWU1Ag!2Hcw-Nd8zr%_&$mUnl}|x4_te$}<0Cge2HB zF^~UTsuYLd590IKu{kCTimo`8NE5yJnelW9v-(L}Sn>EUp|AT@u!1*h_gD2OYnXGi z8XY;s)p{##lg}X>TaT_z69bu>^FPnQd8Xhmto-h{EIZ(WiyV3Ig<&{P$cd6{PV=Rk zyo#Sc7Wz#K-89}Dj#xoPA%#m(CD36Vl^?93Jm(-kJ{mkO-d)_j5gw3QrZ|89EemP8 z3BBwc<`p&WfA%NtEfoEYB(^hz z?_spE>8A%n%bpfh-Bi?hSQJ!1(m;&LERswAhx%rDD}rE?B3zh_W%0BT@6+MZ>Btf% zm*>g-$_{xKjB%C22*zb;A0}>^(>0M?E!Mn&R7b(JG%olkp+dvD3^PAd@y)(hKti#8 zR=5P_u$$1j=yuJ_>rKw3NC!`-j;C8|w`yE_t1wob`b`zRE0QcgVzj03h)~Za0D}VJ zP`WMG&h|seYl!2%&8370m75|uJ*+UQ9zxsD=Itzj21wJ_0|s9-#@l+QvJH8Ynt~C` zekN&jgl~A_bX@n~P28x}rRA>iEl?C7e!Se@98ij{z^E{`eMr*2wquv2hZ%Syi8{2_ z@aeNXTx=eRWKmZ1iYM^aM0#=M8Hr-6v6e+g-T8`I=OmP!mbZE+A|=e?M=9i0c7iSX z0pcqtL|VBg9uWZ{#)u6|Wp{IUQff_2_!>2|Z|^)DN?Ezj;T~@WNmQz|pYDg>Tv?Q1 z+0PmraN6Se6)V{3q%Gzt7uTDos|TF)UYtRA5avG!sG>_jyM36|`ieO9#X+h^9^ZUQ z0!^QSROXv5_j)~yWO?>gaDQB!1am$Vx=-B`rv7@f86>e4f9E&F_wsKdA=)Xua3jr^ z8d58978uXZhgKwc0n@&FrP?UOum?!Z)E}^2yY7T%sdnAZF51V|8?mWh?K*R#O=fXVPsQVxA)SvlBV- zai~T@pLIti1u5sU>r2@xi8}2uD&p2ryQmG;Or)Ed1S2i$QAbTy^5S$cqS(5$cnR2= z!^_m7qjeLXZZ|M=4JzJz8?dn9<+14w8Oqcb*`eV($X{u79`Cb&`Kq1C-N<0@jgu|U z<9@h&iK=I29Q!Ht!;aDiZyCjPaY1R`W@SRT(pwgGDXIHGQG9eS3>K@aE~9FMXZ;E^)#oN~Z>DKm&_n|(TqU2<@As{2Mn~Npt`ThTmOE}f^zIB0q@3~_&1xv{ zboW1qS$>IzUH$o<^?DXp9_R4H`Wr)Ij=7#>f}quWIqwr>HU!T3M!okBAWRk*4R_(u2ay5ouAA&TkOo z2Oda^+9~zydyMdMGh{p4j%KHd=~m9RmTY)kFLGUbhE@*zFhkweE4>wdR#1r8i1JjK zP5UGfj8@J-K8KH1e#siDc*~2HY#v@Tq+nB+6ut>6FC;z{3kCagIq` zn^6CDta<;NDT(j0=VX=NBp>kb&NmZrsY<;mM{BF2| zZ2a-qd7OHoP_SLw`(Ro|X0|h8Wp*S%sds(k=#C?cGHPF{j+Pj1J}2v)E9ZBA<$Dok zwfWo1h}nQZ;roExq9WPX3b=qStZW{+pU2ZUKS}s1p*k_n<@Y4?D*XvVCXN~i8@!Fl zv)CY$B9@-oLde8t9mz^yuhC#MZ*4~lCbMWjng4yL8?*+muam!&r}~r2=sT`2rE+Ll zR8f`RE^PV|#(BG+shh0iY+mioJzR!NIig|gD)FSu;_+)4+p%{-_Ha^JK#4043dx&Z zHswLP9uUzI@Jb7C6PBAhL8Z7?ow;Egp8HwyHzw;ueC(PM>f!?vH2$E9V_y+2Y{rX( zIwBk~?E&?nKJ$E=v~a?z$E=G_&_JubNy?wx<#i7ix1A~~be$LmBUDDOD1ftXz$r<9 z!Gqb<$>MW@vWp7!BUXATN=w3=gY+^ALSdyxuSBAf4++*R%fTY#?F#;_miPdrCLryd zM!N?+9|x)Rh7S~hkOC{7*nSbZk4#Rc0))uArt;8SI+nA$11I6}re=Ge6x}$V{m04A$+W3|_|Z7t!Eb_Do3ibSe( zsKcp+4}zXl>--(7B(!+UHJL0owsYlf`$a2|llO_`6qUG)^=}y6DcrAo?2S7w z(+irm_AnYriod%Pd-6^5doaJtY!P!Vta{@y_;zMZ{1r3$Qw6{TZh@tLify)r+;tvo zHzP?4GB8}~2$vwtZL#lz!T8$hI(O|+yZhCsbdy=xl_XUWUei=}B~xpzB#)gxtEb(D z)1d{HJm3eI*^nreXq7q?)#mLTWAX0fw^BME(}A9Wd{=>XGIC;aN)uCL6zPu8&h8m~ zcNZ+5C;9pqNzKT&!?;#zTjSHS2A@RJmC_mn4^X7TP{2@(1Gbm8A5PR=zC$AH%sWK; z5oT2HqIi4g_ZRS8p;!|hyjPN8rF$$|i!LlRPShJ9k?5pJZxQ9bn&7K98j4wT_o~N; z^nQlwFj(KSJD@r!)RcWPSUT2e_e|xmx4dq&>2P(uOxiyB{1aB@yB*N^uEbF#acXk1wWEj>cyeYD$J0&gooT2Y z)AHy%)TcA~I5c>rr#W9P)ja%U%RgF6@S%0F2&q^~U?My*Z>=VFyFIy|d1~ ztd$p&S(-{dyYAjZ)*sY_loyRM5rfw<42UL8;Vjc$)1M6>bTI4eEPT3lmxkZD2Grcx z+q9`_4R6eAph{@?e0-%G4gaNb4=@8b#Qh;*=l5vnKL)V#;zNUA47x|)%MNkFFyAF9 zex379@IorVu@+ug{hUVvLl)eET}p|AR-dF5rb@I86UF;Sv}Q;N>C;n-QmKk9*#WA|cKRDmZ8x`V z=u`rhlXzUqTe-SK*o($;bM*_vJ)Nx92FlJb^>w(scZXg%O=%pzwyWKXcbF?_aX ze$AmfBb8`3Gz|vzj~#|PSk9#~B?z`O8L|%n^iNwyjm0)a-ik8CHp?F$&Z0@qFcD+A zG=%AKVR+!jw0YjOq!sa@;MIwzDGBpOQ9=hCxN>Ur`QV#N`l;4dc11Fb;y&n;EU*)7 z)+cr$2p18Sh|D4;nGjklO3Z4`(vWH;heFyBOLu{D&RiX!U#XarTns5jl%;@)=X7w*fP-|Tp zKR*6Mh;({Iv1)YFvzzJP)%|G%$2OcAeo=o9p5vZ|vA*4R#LC7il^?&k)f4P|IA%v$ zk76tzKm^4W6$LZ&Tx3;Q+ubItj{h}VRzrt}g?>;sV5_D6NOLI~Ik|*9Ng~vU1=5y9 zoAMl7YkQ=#gkbFl%K^F6fpR@eWltk_uv5n82Bib&D}*0GN6tUELgs&%>Tu~1uYNMw zv~SZhoFlZRll9K+D1Fv`k}8Y*sE4{~iQT1*YG-V^Tj!@vr61OSui7rN!y z99CNe)sSyaN51No`f#yi(KJuJL;BKeBa9b;l-`X`kko78?46 z37Nbm+FJ6+U=r}%^2;i`Bqqhm5saocRqsh~@G~nfzR3SHjy;88oGDnun^542r-%L z#%~hynJ09YSP%O#;rn0uOyFrJ^Bu4YVo__LaD}0Fa==XmAsPvfPLE=I6PO|m2RO{;`Ka%1|Bl(rEyK7YRvgcgx_~DOa zY>l3VOKQiBM{HTht=S&v01IZu-0o~cz=qkFxVuiwk16s)dnbuO^o;p97{oKHV-%}K z$Pt<2Io)71u-;&8$15_{^t?^Wq}aU>TQ@ZnKJht|L5>K~F54lnNjUy!KxpQ&b-rurKibqdIodXUn>%JQMagjg< zL(4;>9X82!bbdYbTdb2eM|WphDAtlJB`%V%C%f6To(bO)-WzU0UqP29*KgnZ(yfpA z6_b2@NS}fB^=>HDX}dn;Dup$JD)!u3j+Nxvw_z3nIl6{%D9cL0Q$p?IF>e<#h)3Ql zqr#S%Q=A_~e5LEqYVu-H(>Q|@hw{C-wD04UPBxgG`#QvyvFq_;nM~-FD>J$w&$zA# zuAHt|)D7PB7ttIH9FUtM5QO;I!4Wh|wF|9Cs&KmCmPP6X{0J3#^rXhT%l4`G5G=rq zw@_a5<%}5z2~_FWnrKTXz%2W`%cz=$_BH$KQo0N~G-e{l1{+FrD;qsnrP9e#(;3f| z@Z-*#&+BiltcZ%A8d2tEd73tV?BDR3e;0Gj_S)PQdW&;%U{q_5X~~Q|_RWydShXiS zjNPXTPoatO3&eu8ES!N{lZ@|-fNCVYaK4jR)|zg78*SOew6>HZW7J`PSGH=$rI%ry z(k3B+;N9J^^McD=Ec1r5bF4;yX-r&A~aIfYtJfN_!<(_91sTxu6a zK?3$yCzcbOIFb@bZ2)xKEzxIVK4ka}D*=4_qG%L@Fdlw(t7-k8y2IA)DNi^XbnW%c?UZQ!Q#z6YgFk0l{Eop#Z4lhdp0BIqq_kh)-SP zlW#&XBWYcAJUUj_b6Pq~a_Qy(>?t1s#5|Nn|I!?|J#VJisN@*sPu zq{=!mQCp>ScSN30HNFTT6KYqqo&jE=+PvW|-#vmK6OXVW8gxEE?VpROYxS5C-p08k zjR$JddWY0vM@{Hzmy1Xo$rpK$2+laVo2Z&pb}_@oZj3SN5#g?@oHX`DPE_U4tanA5 zCDfekt*YoOxGO$^A+8%-LE3h(U_}ykiYnKylw`<#?`^k5?CWl1MW3v#0A}>N>Td>P z?(avu4;FlDgi630um=J6Dhlm>j(5$!ll<$JxZ*$|$y8|Anh2c25OGb~o?zZ4X~w+) zEY>J2Lc1)=vR~-#W`L?(w6$O{Fdlal1rsKiMDE?!t(rSaJ%1qdb|{%9w$3~14-UF& z%$b>VGryb&_%yiXlZNT(~{k0wz-J!U+j6=WQZGk%o@ zRBCusnLAxI8DAs6QD}pfRQ^07tRGsZftnz&zgct?8TQ z$ZWLxZF~>>Q+uG>g_~FC9pwcLbB{cl9Hr2q-Q}rsmsDYTzRti*{QKKu6~(Be^v(|` zgD7Q*bmk|#oag80iKZJuq_})nU%PNjexlc@?iBbK)gZQWFGg`z}C>GzI_xNuVo!sT^(hU@DG zlvLE1;ub_qAnbgk#7+#Ky;@qR!z&RE-g}Cp-F(DEBoNMzHUqnv7*? zLtOGiHqrYCXgy8H&q2HaY@0zc^*UVeik4gAiygj7Yq;vj?o_AjzT3JeP5i3Ql#@B` zflvfd%y3!uKtGWj$x_R@4$|6r!klaKj zXHwM~oEXF>(G=zxdf%{^e(ZNKl=81AW4QvG4KwP#RrRf%(0rZ7as<|VwlN(cv(ao0 zVQw5lm9}o%SZI&dnLg|AuGs_YgG3h;KJH3!vj7{csiX!xTkIBw&l}|oJxW=d8?GUfZiN_x23Q{dpdFDQOG)96~fcTcK^)l5>Y15 zVAZX^RiIZRfVsAYWPCWnk*z}cW1gxm3i4_e0lLJD`xzd3Zf20FF+=pe|>e@nexoXy6`dN7%Im} z2Un_1b9+c{XnhE6NZj7ger5uwl0>!qW?f!0x}s>gYWZ|ISd)y6k?lQO1+le|s+NnE zV7fe3Gx32>Q$bUx-FioM2VVy`^g1*ev^bxkkc*IDfIM0=sfXAXbR#iC-Fastwsmko zQ3SuaVdE6zO%JSlf*UKIr5U!{Z3ef`hnBb*_yCrM@Nk6S}d{atbY}kIQQ=SkVr-}7ez*d;?Eoa*Ca8OctEoz zVY}{-m+c`J{FrBK>H^-B}V5)v*5~7kx^3 zJQ$tVdtKAn+czGl$@s9)X7$a`l|hcUV#{!;D^b|}lGJ4RemWv8XQ2tMNkH}n#Gc`N`6~gUQwmYi~$kqXW5ZB;K0!LxYn^G%m^jx<_EEQemQxb{i&SCmscq zQj4BcUN~YcN8pM z<}%_k${)^pSiSz)rW6FjNKVK|Xy}e&@-aMo*8r4jTY^GzLEaQHVfq$hK<%1VH@Nv)#1#UEV;g$O7t=)P+EJ32y>DT!92lL3Va6y^ z7g(0;J@Twhqe(tmE!n|)jwk4wgj9?qA6nW^k%0rS1MBlRT6EsC&>g4l^i%`IJa2Rz zE>?Gi1_GC-dhQ!Y&#gVbI>^Xsm)<=puGNEI`H%8&48ARBoWH*#tc&vF&e7OFK15N^ zKtVo4^C#Veo3vJ8++29jkiw5ho5tYh9$Ibtqkik8Tb&b`CFgmY#YHcN`Hniw;{vwvG}nu8CKDbNUU^;w z1n+m*^A~ih@<4of()$MYwacEBxXvKJJca(_5kf+x$d|A_j@t6RChh(xi!IsjNqq@V zqo=UbDie-BRn9vg$V&x8mYCzL93j~*ggoR%w47Ofu)EN2CZ{Co>^9VYAGTVpxeKs5 za>~i|bg-LDoC(wp9NO(|yG+-&QN?cW;tfZ3fJktOn?19>(!`~qSLM96Mkt%>)2{DW zhVb_2j(<0KhTYZbraoqJia6*S*vgNThMcTJe{kTxJE^xhtvvKTcvDi9F^8JU&x>|! z^$t1x75KZFX3ORV2#o-#)WP*q6Z6+R*N0`e!~IoiDxEh|?`~kKcTENddZp+a5u@Kd zKOCk6O}gpmDe$YUAf)bgG*+1z*|=Kova6l%ZZx@MH^zRqDiWA zfpw>=o}VWqo>J^9iGshS&ujC2WW}!fDcmn7w;VCks&l3zDAd}UaPKHiYo^weTcmjZ z+sO{28uWMV;ZWbASBU;(9356y=i*o}A72L)w73O-^vr?|(c>trrj;b3Aqw&<<1nlh zjY+bIrEbplLH(GcMwwS$uH=5m;d0t6Q3eo;pV&^si^WwyyZ9ja+Cw|+4zOb$Mo=g3 zzOtyq#u-0TOljt^BQcWUwytX=^%UV3?{KnYce*yQbBB-tpgKvpxQQ$p=4lBjp>A|N z?4{$G(;mS3k%m&v*>$Hu?p!_#{24OaH8W$%{8PKv zLmKQBkM}GKAD_8l!YzBno~fFXsdC|B_cH-b9V!h5)*+@u=vC-;UtmCgUqRnaUqatp zUqirkK~^YV!*Q!!ixpCtdgE?LV7>cwS$pu&K|rXtLgyFO^X|LGRgRzgsWu)BVx9_C zN}XUP88AFcJ~oEcXQ;P*R-Z`1!v!>nI=+3=*ACAl59$TbM(Co|NU-X0JAcI@&~l^U zc_vF6nAYb^+LR(|$$zT^i}KMJeL1}2QQzc2TW)oQEU_!|0U{5xmGhCccRca1PE|RG zi_j4mX2d}P*xg5O_q=WQLh}0V+I@tybqVdvEPdW*Sy93igJNm6Kj^Y}^YPWDP;2K? zkWAwsMb|h?bDIG3CziLO>CHmks^pBS6W{NzY}xQu&zI zJRcaJiYzG7IZ^pNE>&c=UtJ$3!D@AL#hDc>?wCrC=@xzvW6al(s5J_SFyX)u(iWS# z+nh~7r#mJNbhwB|pqkw9_T_ zZB+<%C1$kl7FrY`+9goi5?e~iHPi#SwJy$6ZyRq>8*^9MjYbfbngUc(uH!JYNjclE zFbBmDbn#vL-Z)#)2nZ6F8wJ-ToDtK!wpU$?jyT3iXf3P6cXdRu8MwdC#DK3pe2!k0 zjF@XSZzfyDvBo8z47n5>HwV>_wRb0T1ccr{w;4%4YP|t^ zf7J<&c^F#IY=|Oj-O^~%@DH)C(EJ*YKz8mC)$gHKEjwVfMu0vy6x7_?W7EVoXcIv1 zGyOW64X(y;qIYO-%Qu*pklJ6A$StVbEYF56rx}PoGypHBA4jYFd3s9YQ+&&M^ZW!7`3pZ--6%lYp7i^d+qj~2&f=UhM2CUU$dJ^bC{ z+j=``Mol=-eD}S9i=T2YG@WTz?aaS-$_ctVGk@$5*?D$2z zJ*8Jk_@HBQ&f0UhC$`2A6cP?uy_8jGIX#k;fyh`Cwh99uOLXWmDxqtTb^|Zjv!{S6 zF)&A)s_cKEre(r+989y8f^|Ay^2NFQlqh+3TX&W6Cdy{RCW`z#)-iS)-4$6IRF+X1 zxj7rzLVCpXYCqx?O8>Xafzyb)sFbt3?NMc z>EFSjZX?ZV6iZE{z&pIgD3v`oEJYOS?_u|uR%du#hG8p(tH9NzdS9`e>)UAie3ahapizuia zpyKaRT*#vw!SLnnreynQJN0KPFqaW8O~y>>Wr`cNOSV;0ai&7G7_0rk0IjspCYQu{ z2&T*Ab2xtnj<}HL+@Qj8$ z;*(fPoMqPIu;C-lJ1Js?yd@;)rcYEv&>lYYTI!an`Yyw$Q{}3jflJ>Tf|tH) zk7E6>IF@Za=;CF(ZQI zz=)`y7cOAPmvHxk_yqj|@fUd^Lujj`f#y|3!KJ;FofX{0IX8dW_W-)8LZ=_TQWE_S zfH>*brpETKub(KOD(+Fvm6g5`bkL_zU4RR8tgswDr&^bE8q7Be1b$ESmS{FHfA*x~ zsycq6n^&1vVbS#PwtCun=y2V7&Wd_q^K|%bD2`hhxZbY+wv6RyT>p~!fh;rri8lTD zQ<^oxLaPSi1B}=Y9qYZ}P!5%!!QW85bA{j;fPU0%S*RFwR8U8sy(qubj9fcfn#xv9 z`w~GTd=y@C#lTy~gDDsu_?So7_Rd;aY=h-%9+*X&WHYTzolcnfP)XFw^O06w%GN+v8F;-PXK!?f!FA)&t?)&a>E+#rMu{ zvPwGb&m%YU)!ehUO|~h^pL%5LMn0Dl32zP9z@IG{B8nu~Qmv?)a8G{BnZ1vBN2z=r zcF4zzAFv7LeGiTpFl{v6G(yM>$1&2|D7DIzzFHm+J@j~Lb=NwG^w5t)_{Bl+E#t?Y z4!dN(h3&W31dw*ITPa3?h1QLT&zzqpzMQ6Jdw<*I^nTJ_(pJd#B3>3Byn)?xtevIz zmn8IjTHQ{SieK!^o!qwJ@D6*z#R*n&8@12Vm{JqW2*BoMC<__5FJssGAAY101>Q@oOK#DXs;K!b7_=x<88)Cdv zO2qHoC_)zCXB>JwHSnPm8vG>coHpjKwsndiXC?r=*Oq;4uSD>t{Y&+to`0WBOa4o$Y>)-4xj%A7< z==~t-fLeLA%Xz2lNPa^ro-^-@kH0ZBF`~IRJN>A9r0$G?pL6s$Q1+8(F*3~eVRcX- zXA;b%A(0pHQ9a;%ohw-TqS&+s^%Ghn%EhJRE-ad$E3fOG_9aSN{Gp6-jMEnWIZ|ey z%4z9c`6Kvq7%J@+!bwQ(m-IE^Qm3H2xBFgZm6sW-6(^;Y6PeL$*uF^dfmnzo43ofx zrh!}XT=N4Px1|~L3^OnAjdj}*YYQEV+A9SOx+UQUu7@SBS6-!0$Pb=d^c?8;z4*0h zX-osnlXoHB6+a8WFzA6<6uK6=wrk-&xRBow4zQW&_QrHEmhn7@NZGgICtmA?uR8~L zV?^W1;hdQWO*$e=k-HYLy%!R0@NrH-spy;l$J>Op!0QR|u6YZWqbJEd& z9{BE7*HD|+hWG3iQ%^8u0edAyj@#8nH3qmr8xk!Q`g8}$!9nh7`R zg2>hlAc%6yPpcM?ID`0S+Nt`mF}5f5*zw4Z{q9ym&wg|6l~$rSmv}gVTtyILBJo9t zV_?WCB+yra0TcvZDJigdifvi&-6$v^?-g_31K-Or*yk=#fUDQav0uvgmpU>d@WA7I z2QU4+@}{@>ZU3mx3Gp7~&tSC&=g|~crZJ!9z0ON{$ew`V?ewLX+uRBpdGyQD&WFQbL9Df4_2y|$Q`w6qe* z%u2&JB^4zV%vKX}iiPcf5b^smwPBaVQgLh@O(jkC!X^s2=dTmbj;0yzUw~)gyCx3Ix-AidrT^=3#H6g%j)9W zif7IE=`BhNBrR74r`K{6;WT-sT`RoHmL8rYct&AP$7xnmP06T2MILFv^%3#`&WHKb zQh&Z_L-nIs;P*DN40~NU7F#@qW);5~m>kku3|GBT@V6oKbCA2@QHBhDDno`DaJHd( zi0{iQD&8i=DMEeNogr0rHGhu~D!~=c^!fEj=(?RU7NQ5o#`N@wI;5xYRYlUiHG^ps z>{vjDqJlT}Q*M-SSM1$HVO%)1pUIoB3iE=)IE#2osCs00r>`t&Z3z+54<-A?I^RW* zRw^C27g_LadzDlwJm;s3W_C?Hwg|Vmy<9V$JYAiqG+|^6KPru>7h!%iZtH+tYD`pG%=OPcXVOC&$uIFs$9h;M|+X~#}wCYGdi$z4x z=ZQ!hDR|$F*!%gsvJUvzX)AImUz804iF@SRUV0I$V?5d!^pnF7fCQ7R>OcBa&N zr~E+?0VB5ZnW}Osh8}$=kOYMs4nhb{|Pvk~* zPv75eiCnoP%-7zLDh}?@>c35NWxyXSV;T=l#|5}0ne4^!sp!lM`JA}<+VANc)d!YX zdwMoZ4OLh0WE}znCVv(=@-!= zClGd;@H~RU&bZ65QpBQ}wlp4PIWyv@b(X=>v}pUdHXX};TK9tkk4pIBZRSXOf+jd2 zjazfF2+i@^Nu^_)uN|0oEeZ0&Tx~sHm9+6flizt}X54zLTzNR7g+gsh9&6n>aaRm$ z0q2zhd1sYNljbk`4x}wE)X>N9jGeX!;#bjDh?MEWJ0LquT*wOg1WJ_bgH)cJ-AYah z&0FS$OWMz>^86G)e+*1D!5jABsk5*+UQ znIa4pX;AYR8$jmh?kU}{ARBmh_&V8d@U4Z;x!K;xUH_}SYY&TY`~H=Vl<<{Xaj3&g zGEp=0&ZSX@?vzM1-6)x^B+^Vt5jwf#QZyPFDs(}2%AIhKOiGDLZjnNA(41UPr+#a{ zGw+l*-`jJ3fBc^3H;?Dt>s{~Od#%0K+Iz3}#b;&sVYmHRv-HjPEA35)Tc)vQ>WRcy z!Qs=o<7t`;^soF}Iw5w+=-jH50h!Xhztx0|95dvO*^;-ZCr#ZNLu_>e{hqEcyR6$5 zvv26J5*@Q`>s4;N5A#xX(gOeefzl7{%oj{~(6c#$U}CZ-AwHK|^&hW|^mdHK-H zp9ZHdJK;~)WY{gy9ug-os+_xKhtdV9_L!FrQIFHr9TJn%pN5#FMKId4d8}K80nv}t z$+vIL88WP*rz=rBnfj&t@+@BG3yV1ucF&!D`Qxwe+indS7~4DzTjAi}nT^fZ;fMJj zTbH~&`GDVa#)`FLay0$AGWeN$hXrBTSfaxjBl3(T-r0hmf7@jmsQv59oBpX|uSPp6 z%?PyYd7U_Vu5O7=!PN^F>K6w`7qwm6YxHpZ^(9%tQk}r6&R5*&&%7fkM^p7_U8XrT zq+M_RoKU|hD6O`-!@gmQaEBRr{;|e`)6?tuQ9kt# z#4$@QE?hVwqAGg3$GXDS4X)!ow{B^TO;4=!H(GOF=Z@iplOr5YM@&d-tnyuNS?+bw zb+%ulkTCGxA~fe9v2z{#WxG{O2z8gMb{ssoH9NW$b6mGv(|lJ zMMS`p%+_eJm3y~4J<5G`gLA-+tBeBQ9xGRyO*i--`y{cZCHQlRp*?B{GOCo7W~uEH7CUlmx?Bkd`?fu>e|w7 zA>Em)7E#?Y`23T*p6&16cZH3*+wSVo(zC$Z?(;lvyY8NLd+%5FX`?pxu-onVrw9Gf zlxHYu@GSV#_^zm>y*>+S3JGp zT#tmZyUQ`pp<;c01*vDStA1;fTYyk!m{j?d!RCVNzlE>vK9DzF(jfZ0K5rFi{gG8! zT@Asrq#x@Or!O<_>As)TWqdbr-Jmy@POobDJ-EgnUr1h3%N0y16r{sUSF*C3E z_eabJj(MMMcvEJ~R4jCaNp5vA4?(Iwf4>X;yw-y^^i=V77ba@~rmh z2~#6)rTcibxiRieU-e89v+kW^_nV8Qx}~j-X9J%XQyL4n?&r2|3p#K3cD@<8!7{1! zVRJ=efNRJdFHe#40xwTZLDkXllmTt(=BB%TxVpg)1pvvxJDZIT6mdtoyZx$E-5LDE zL+9A#f{`k<^v-;vX&*Xrj@4l2RnFZRT9>Uf{*0H`q8}b^_no?Ro7!TVZRc|nvUjE3P>xPf&Rrxe67QR+w%E`hGcO<^A|&H%_Cb#(9rLpJ&Muxq zM~p6swV9AzvHKvm&f$Qkm&e?N1~KGx&x0Y!2Q3|=oOH4?gAY!9p=|OoygJp_HukWM zX6#V1!AEYKcJ2u`ifG%sAvNh!T(jSJmbA2$Eh4`2_Oe>Z$B)_wRrzTWdm45yLD{)(b#i_%0>G;-`I=i zak*ZutjyFyG^-LjnbXVJ^hf|WBBTqWN zDpZLtYkjoZ@?@dQ`R&R3q!W#=q#do$D!}+9UJ9St`Yfk z{2Lu}?R&hlvkvEeDVpMXI_EM^d0SbDy@B&2Oq3tSiK^Hb<{7oSwmgW^I3oFB$1`ck zmY|{T8MTuy$8dk4PcaQ`IkPoNqf`4p`fj$3PD*yx3kN6Lpck5s^XZdcBvz+VEhexR zNha(!)i)h+zgVz2L2P1X8njWU{EUCSb&6kcdEg7>nhRkeN5^niJy+ET33>jBH|Z(s z=SgcERy|Huno7+cU-|H}htru`_ zZ|Cdd>J{%gkGD-S;9Rs@!cIt?x9Udt<@n0f%8oWatz`AH;;Y4NKfI%@S^NB$S^bFB z!&0@c){JKv1zA3?8gWSZ%jd(hcTG;H^mkEGZhO~6-K;ZdrrM3{mD1}8)$Qg1&L1{b zs*+>Yeo&<(<##+&@}^XWc{&WxSDBgfj(_2kv(D{PZ-do3Gcrq6hgLi24p5prpK;LQ zP5qb?hJr=BFdJsw@}jV1g-K_fsv`BN1$C)PN8iQssa^#M-j*IJH+`LE{k|eidH?2T zs#WjlAC>P9Nt!JvwGnLlb%2|bdou*@cg2;{@_hFU{b}W?S=*!RMQcVo49q+_>iD83 zhL1`Xd$_9Yd!%Xq;fJf+D(Kqu)5ZB?Q@1u3#@1I9|L~aV)i%Ushf4NtmFaVi2x{IH zO)bpbVBpF+vbw&@`sG49Kb@;t`4_Iaa30K=?&rgMZ|;*jzo2sQ=g!VQC0C7it4S8Xxs+@+`%PtB@`1f-lBTM-3-d1SvdEQ|?Pw<*O;g|ACE%0G zQVb%zNNetpTZ6La@Y&&w=Q>j6iCg$Pr6n%E_NI3Vcgid(!Psx zNyouA;=JjT*}f-BCVp@;PAc^&*l_Ai=-~CA#%#6|+KV*`gz7CK& zX?>ngUhgDPo6=G}RJv{wCsTN5mGp>B%UwasPD<3UbC*uXr<&0$P0q%rYFi}s%&;~o z6gF+y73nH96?M6hFF8q$+e&ZUUioyq=*JrkEfRz z4X*slWj)jPympsh3H}G{)7|V!?9-hkk0(mA#IK_!y4`cJPQSN&OnN-|{BQna^h(?5 zjXA<{*J3O3gWvr3+fR59JbJ>MpB7fN+@4sg>b-BQWV9$`-8F*{H_0U1;ET>1C~p-Q6CC){V`H?D$wN{DWH@?V53H0Xa`gQfmIZw9UWu=YhQJxQBjv5jFGO zt%GA-P9FE)l<=@X*m^VM3STs}Bq^KXnISBrgn9p2mtk|Ih+Dj5_}RSpsg(ka!G01I zQKh@Y3==OS3H+Tig|BXAy`tw_aCmdUfx>NevO8-ZXd^V`((ml~sWR2Pg6n)PzH-BU zEz!ultl8vE6mg-}M|0k&I&sjs_F0tMld!FKDG)j}y77{AB||awMmKt` zwe3+-UXnjc_>`h^{r&LG-ugP#Nu9T^WeX2cbojfywVU%h2O8NzXzB#uOnw12iR zmh|EF;H_@8vmCXYXTM!^-16qJvTKWzl*^+Nl zwifT2eGsdimpoA`OuTV``coTHrL|>j+#!nkS%+V++5dQ`nnOAkuc~xJh1)zVt}8Cy zg*;UBDd*bI@vkYzl?zU3`g&VWwYiqBwfnXYHRsx((uD472elgQJ1I#A_h)IP#l-{W z#ul?B8+Mx;I;pFD^wRyMKs878^plKHAD=$%G449l!%~^~Y0QTn<-sZ3``-a0>H|lP zoGSAa9s7oHE3;Yi7lujn$AM*zT*5gZd)X#yCJ051l3YX~K4E-#$F6v43r4?)nO zAC6SjpMmh%mhkupT}_||Xj(X4o&xU=Ak$=8DLBT~O9KM|QEUZ5Xr(#1lf#KN2Q+z#s-*Kpir(9RD)&$(6o3Pz^g}{K+mX8xez~?U)Xp6 z=@-HdP#;x?FvL#-wVwv;L z`tIv6WDNR2_|waAiV2XxAsUc>MX#4aPm$%(2xK+!vEVgXpsK7UqKKME9I_y?@p27h zJw(a;390y3euVGHU)zwz=JYyJe;b1J04W%yWr3~S2xD=O4Us*j;ezG*hTy)&R%jse zFmx~D!B+B>=3#n(sZECzI&vS8d5Y``{iC5U@ct2nrXoZGL&jB5=&5XoPTyH9_)*hu zkQkl)&qj&7^J|oFB+-ZpClvz{n^377WIlpp!ZAWX$ry`n!~kJTj1D;>7~mvqBXrAx z0uEAeGY)zHZVmYS3@GUg)DTdUQAm%DN+_To`K=#Xr+`!r2+y+TG%Cs-gaDys&DnIo zR|n@-^pCM2^+d0H{6zsEuI1_xw{rbK9W)S#-8o1Ka(=uu#z0a4a$43q?BgJX2HF$i z>5vbC3CT|g?dVL75eIOs3H6D45Fj5P!6C*^^dO(0UJ=9$5;_nwtWc4dam5&VR~mYc zpc=6tYZK(Mfh#x;OJ1F(FqwiQpA(kcWPtIGn~Wth8MK$141le1lVS2XXUc~JnDEGC z;QF}9qKVZ;-1iPcX1Q;J9z1~Wx5F~2|KSD;dpLg1E=Q*{I@Lq)n4p)!pAy?V5l{3x zE1;?D3sjmA7kWU73|#qY#iulgIz~)#gD^(cN{1VL+3RrWf2#A z!|6ly9%^AA1pk23=PExLQ3q&LvO^Yi^`C}z-#s(?q@0k?1dY}w!u&QcU_IEX0|TUtEHFTn%m^rAk(rbT;~@G`JVoP|)k73&OW6B6PWSB#AdSQLYh(5G6h&-- zq@aNB-*vk020$o!llOv1`S2h*>7^9D3j$HJiOXX^Bq($8UJ5<@Cu9C5f5u;D0s&)C zX&e@nZNk847#r)8g#&-lm#O>ZXg~pI21hOy& ztcKJKcFQ3pq@}#Hl9zV!FOkqMe3GOAJU(=bCDzukiDv)dVO^1 zeZhhxl>AWt2*|FWXVBdPdHm)pSu5E>4hJhpSXu>_2!29|BEVpoG->6 zo(uF=M1+LME;N`J)WU%m6&eAv=eQ!kDl{Zw`HDWHT+N3h67Xt7)sX3ou8=8*2_ISm z_2fJZgNz^eW4#q}9z<9Wm3+;EJM`5C{S@`!`vBUrzvf}+RjEG@N$k&Kp{4$HTk!jD zc`P^+``2T^J41gS2VU9w@t7E+zbK_=Tc+Wrq^ CuJOqL literal 0 HcmV?d00001 diff --git a/tests/data/ttf/color/TestEmoji-FE0F.ttf b/tests/data/ttf/color/TestEmoji-FE0F.ttf new file mode 100644 index 0000000000000000000000000000000000000000..811c089b03bd9be83eff0371ffb57f6b2ad65ae3 GIT binary patch literal 3396 zcmd5;duUtd6+h>@vSr6l*^(Wni6i;ZtZCZGy0+D_S00v9OiEoR15+}4imz4Gi8`2Y{i8B(FXcQ+tdt}zYHy*xTS;?`bRrw>lhh~lv1w^!q)BYeD^AH z9)0||dpY;KzsLE``R*Ymq8gebg9Z*gb7atSh_4Y<%|ZIXp@9QO0`Z+4MAZY}hYlb8 z`N3Cf{`La+SHNF*=I9S2Z@;(pCJ_&T|K(^VG5yqUA}vG;KAav;pBmeny@~ORe-U{u zrjiM3we!*UVf!(lHwA%r$*6~|AACnDlRFvct@u0mzk*Mvr$!TjA2ns*zX1MNCUJ6_ z!qf`=6X1iBiA?hLFCMKUs`wrFYtvIRxy#Yl(?pfWv4#-I-T>#yPpyp9{rG=m7&jrI z%P*`8av|+tsZouVJVK4+p$gDNPsfHKF^t!E zfy$u&j%N<@DVpYN2Hc{YYK}T@Ycr3li@x7cg(I#sP_T@KXm6 zd)i|;fDESs@nXql z#;hg&63YglIKZ9IK;#8ZYCw_#k841J0#9ln#Icrss{w@v{~EkIXzzXRQAGQX^ag!M z|D?}YaUF;7_HaKR=U?+V{tLgw@A4=7AKp+EDxgBjR6kLN)w9GnP2v@GMb60dg?tfS zP**nlB+!Tuua`7dNrUX*VQ5eXrqA0GHGO$&F`|Yn%Yv(iBB-2Y1G$J**CBd%f(S~G zI(y832`CGBjpf4a!tV>Wxq@?CvH7RxmYys8ffuf-?OajR{C5e@;yh|-z<%F&QQgAH zRT7nPV{^1o#^UwD@k{yqrOgxb5nkcp#l^zK$o#xy1JP}X@q=>c?&rbf$wnPVxbymQ<`1R5%O_QD1g;%oOV~p!&k39$gb1b#l{VFMeoEa zX5M$tS~_3Z8EQi!67reFxi@buZQgRHmz8jKX$e>2-D@p#?fK=Bu{KnwoCWog3OjLQ zH*2{cNy-b7lu{qXePh?f{JuM@wkFiQbIW?aasQ=kcXPd%=Xa7E^W!!?i5kpfWaNLLyUX_88Y4J2fPB5?kDlW0*%ChP8aOrt1Fy5hazu=b4hb_?6x`-Fp@bPznc z;$5*`jp7FcnQ?7t#{0BMcVDaHHzKPT(e#sB=xB{If@r$p2dbtflOuY`F;g_%-P#AE z?FEOiM`YPi{f+2vkN(C=@@Tv&oj8@uK0cbAnwi<=(8*KDA~}{AO}Z9iS#*ppjovex zbLq^~Y%b+`PEVzaCNrst$z-)N;LNXP6WKdVE;}(@w92JsGbid@t4t!DemtEV%Qb8j zWF{;to%C%LWhcf{IVU?L*J}YO{Pm^#G4TJFUbGhfL51A+;#-eDqC&S)g`hfGlmHAB7woRW$}1+es$v>SXIM&E^bM79gOgQZFsU6uh* zBpq}+{`8&jeHgMm$kiV}F8U6vs}(gX{a`0-+QB~ro%A+JcLV6_snT54U7$VOal>96 zGwp+C-?8z`^<8QcY@a`W-nLgw=@ji79mBRcwram~_MoWd+Aj48);5?$I;B?HN8A@b zatt%0*MpO-H9R_+nTE`{H5fY4G9-)|Ekk5>!n2q9!Fv!7sk$;vHvxMPKM!m~*Q{4B d)DG;Z5>SD8D|O^Oh^Uf&5I%Aj`4H;>(buN5*Pj3Y literal 0 HcmV?d00001 diff --git a/tests/data/ttf/color/build.py b/tests/data/ttf/color/build.py index 8a1269316..4973851f5 100644 --- a/tests/data/ttf/color/build.py +++ b/tests/data/ttf/color/build.py @@ -8,12 +8,14 @@ differs. TestEmoji-COLRv0.ttf glyf outlines, COLR version 0 layers, CPAL with two palettes + TestEmoji-FE0F.ttf the COLRv0 font with Twemoji's GSUB: U+FE0F inside its sequences TestEmoji-COLRv1.ttf glyf outlines, COLR version 1 paints (and the version 0 records) TestEmoji-CBDT.ttf no outlines at all, PNG bitmaps in CBDT/CBLC at two strikes TestEmoji-sbix.ttf empty outlines, PNG and JPEG bitmaps in sbix at two strikes The GSUB is the shape Noto's is: one 'ccmp' feature under DFLT, ligatures for a ZWJ family, a flag, a -keycap, a skin tone and a subdivision flag, none of them with U+FE0F in the sequence. +keycap, a skin tone and a subdivision flag, none of them with U+FE0F in the sequence. TestEmoji-FE0F +forms its keycap from the fully qualified sequence instead, digit, U+FE0F, U+20E3, as Twemoji does. The CBDT strike at 64ppem holds one glyph per index subtable format (1 to 5), and so exercises every image format mPDF reads (17, 18 and 19). The sbix strike at 64ppem holds a glyph of each graphic type @@ -245,22 +247,22 @@ def base_font(with_outlines=True): return fb -def name_font(fb, style): +def name_font(fb, style, features=FEATURES): fb.setupNameTable({'familyName': 'Test Emoji ' + style, 'styleName': 'Regular', 'psName': 'TestEmoji-' + style, 'uniqueFontIdentifier': 'TestEmoji-' + style}) - addOpenTypeFeaturesFromString(fb.font, FEATURES) + addOpenTypeFeaturesFromString(fb.font, features) def colr_layers(): return {name: [(layer, colour) for layer, colour in layers] for name, _, layers in EMOJI} -def build_colrv0(): +def build_colrv0(style='COLRv0', features=FEATURES): fb = base_font() - name_font(fb, 'COLRv0') + name_font(fb, style, features) fb.font['COLR'] = buildCOLR(colr_layers(), version=0) fb.font['CPAL'] = buildCPAL([[tuple(c / 255 for c in (r, g, b, a)) for r, g, b, a in palette] for palette in PALETTES]) - fb.save(os.path.join(HERE, 'TestEmoji-COLRv0.ttf')) + fb.save(os.path.join(HERE, 'TestEmoji-' + style + '.ttf')) def build_colrv1(): @@ -525,6 +527,7 @@ def build_sbix(): if __name__ == '__main__': build_colrv0() + build_colrv0('FE0F', FEATURES.replace('sub one uni20E3 by', 'sub one uniFE0F uni20E3 by')) build_colrv1() build_cbdt() build_sbix()