From d87670b605dcc92846b03d7d69394387928e1a41 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Tue, 22 Sep 2026 07:06:16 +1000 Subject: [PATCH] Draw a COLR version 1 font in colour (#76) Walk each colour glyph's paint graph as it is drawn: layers, glyph clips, solid fills, linear and radial gradients as axial and radial shadings, the transforms as cm, and composites through blend modes and soft masks. A sweep gradient is drawn in the colour of its middle stop, with a warning. Noto Color Emoji's vector build now draws in colour. Co-Authored-By: Claude Opus 5 (1M context) --- src/Fonts/Color/ColorFontFile.php | 66 +- src/Fonts/Color/ColorFormats.php | 43 +- src/Fonts/Color/ColorLine.php | 305 ++++++ src/Fonts/Color/ColrV0Source.php | 49 +- src/Fonts/Color/ColrV1Source.php | 961 ++++++++++++++++++ src/Fonts/Color/FillsInColour.php | 37 + src/Fonts/Color/GlyphResources.php | 41 + src/Fonts/FontSubstitution.php | 4 +- src/Fonts/MetricsGenerator.php | 5 +- src/Writer/Type3FontWriter.php | 242 ++++- tests/Mpdf/ColorFontTest.php | 30 + tests/Mpdf/Fonts/Color/ColorLineTest.php | 226 ++++ tests/Mpdf/Fonts/Color/ColrV0SourceTest.php | 10 - tests/Mpdf/Fonts/Color/ColrV1SourceTest.php | 482 +++++++++ tests/Mpdf/Fonts/Color/RecordingResources.php | 55 +- tests/Mpdf/Fonts/Color/SyntheticFonts.php | 17 +- tests/Mpdf/Fonts/ColorFormatsTest.php | 26 +- .../ColorEmojiColrV1SnapshotTest.php | 52 + tests/data/snapshots/color-emoji-colrv1.pdf | Bin 0 -> 56209 bytes tests/data/ttf/color/TestEmoji-COLRv1.ttf | Bin 3908 -> 4184 bytes tests/data/ttf/color/build.py | 100 +- 21 files changed, 2602 insertions(+), 149 deletions(-) create mode 100644 src/Fonts/Color/ColorLine.php create mode 100644 src/Fonts/Color/ColrV1Source.php create mode 100644 src/Fonts/Color/FillsInColour.php create mode 100644 tests/Mpdf/Fonts/Color/ColorLineTest.php create mode 100644 tests/Mpdf/Fonts/Color/ColrV1SourceTest.php create mode 100644 tests/Snapshots/ColorEmojiColrV1SnapshotTest.php create mode 100644 tests/data/snapshots/color-emoji-colrv1.pdf diff --git a/src/Fonts/Color/ColorFontFile.php b/src/Fonts/Color/ColorFontFile.php index d369e5a2e..2e35b9b9c 100644 --- a/src/Fonts/Color/ColorFontFile.php +++ b/src/Fonts/Color/ColorFontFile.php @@ -9,12 +9,17 @@ /** * An open font, as each ColorGlyphSource drawing it reads it: its tables, its file, the log a glyph - * that cannot be drawn is reported to, and its outlines, decoded once for every source that draws - * with them. + * that cannot be drawn is reported to, and its outlines and palette, read once for every source that + * draws with them. */ class ColorFontFile { + /** + * The palette index COLR gives a colour that is the colour of the text + */ + const FOREGROUND = 0xFFFF; + /** * @var FileReader */ @@ -40,6 +45,11 @@ class ColorFontFile */ private $outline; + /** + * @var int[][]|null + */ + private $palette; + /** * @param TTFontFile $font The font, its table directory read * @param FileReader $reader The font file @@ -75,4 +85,56 @@ public function outline() return $this->outline; } + + /** + * The first palette of CPAL, the one a COLR font is drawn in. Every read is checked for coming up + * short - see FontReader::fieldsAt(). + * + * @return int[][] Each colour as [red, green, blue, alpha] from 0 to 255, or none where CPAL has no + * palette or its first runs past the colour records + */ + public function palette() + { + if ($this->palette !== null) { + return $this->palette; + } + + $this->palette = []; + $cpal = $this->table('CPAL')[0]; + + // 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 $this->palette; + } + + list($entries, , , $colors, $first) = $header; + + // Each colour is stored blue, green, red, alpha + $bgra = $this->reader->fieldsAt($cpal + $colors + $first * 4, $entries * 4, 'C*'); + for ($i = 0; $bgra !== null && $i < 4 * $entries; $i += 4) { + $this->palette[] = [$bgra[$i + 2], $bgra[$i + 1], $bgra[$i], $bgra[$i + 3]]; + } + + return $this->palette; + } + + /** + * @param int $index An index into the first palette, or FOREGROUND + * @param float $alpha The paint's alpha, from 0 to 1, which the colour's own is multiplied by + * + * @return array [[red, green, blue] from 0 to 1, or null for the colour of the text, alpha]. An index + * the palette does not hold is the colour of the text too. + */ + public function colour($index, $alpha) + { + $palette = $this->palette(); + if ($index === self::FOREGROUND || !isset($palette[$index])) { + return [null, min(1, $alpha)]; + } + + list($red, $green, $blue, $own) = $palette[$index]; + + return [[$red / 255, $green / 255, $blue / 255], min(1, $alpha) * $own / 255]; + } } diff --git a/src/Fonts/Color/ColorFormats.php b/src/Fonts/Color/ColorFormats.php index 2c22ee0ac..62b24969c 100644 --- a/src/Fonts/Color/ColorFormats.php +++ b/src/Fonts/Color/ColorFormats.php @@ -12,9 +12,14 @@ class ColorFormats /** * The formats mPDF can draw, each with the ColorGlyphSource that draws it, in the order a font that - * carries several is drawn in + * carries several is drawn in. + * + * Whether a font is drawable is kept in its cached metrics - see drawable() - so adding a format + * here calls for raising MetricsGenerator::CACHE_FORMAT, or a font of that format cached before is + * served as one that is not. */ const SOURCES = [ + 'COLRv1' => 'Mpdf\Fonts\Color\ColrV1Source', 'COLRv0' => 'Mpdf\Fonts\Color\ColrV0Source', 'CBDT' => 'Mpdf\Fonts\Color\CbdtSource', 'sbix' => 'Mpdf\Fonts\Color\SbixSource', @@ -51,21 +56,24 @@ public static function inColor(Mpdf $mpdf) } /** - * The format a document draws a font in + * Whether a document draws a font in colour: it carries a format mPDF draws, and the document may + * draw colour * * @param array $font The font, as Mpdf::$fonts holds it * @param Mpdf $mpdf The document * - * @return string The format, or '' where the font is not drawn in colour + * @return bool */ - public static function drawn(array $font, Mpdf $mpdf) + public static function drawsInColor(array $font, Mpdf $mpdf) { - return empty($font['colorFormats']) ? '' : self::choose($font['colorFormats'], self::inColor($mpdf)); + return !empty($font['colorFormats']) && self::drawable($font['colorFormats']) && self::inColor($mpdf); } /** - * 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 + * What draws a font's glyphs in a document, each glyph by the first that has it: each format the + * font carries, in the order of SOURCES, where the document may draw colour, then its outlines, + * where it has any. A COLR version 1 font's glyph with no paint is so drawn from its version 0 + * layers. * * @param array $font The font, as Mpdf::$fonts holds it * @param Mpdf $mpdf The document @@ -74,8 +82,10 @@ public static function drawn(array $font, Mpdf $mpdf) */ public static function sources(array $font, Mpdf $mpdf) { - $format = self::drawn($font, $mpdf); - $sources = $format === '' ? [] : [self::SOURCES[$format]]; + $sources = []; + if (!empty($font['colorFormats']) && self::inColor($mpdf)) { + $sources = array_values(array_intersect_key(self::SOURCES, array_flip($font['colorFormats']))); + } if (!empty($font['hasOutlines'])) { $sources[] = 'Mpdf\Fonts\Color\OutlineSource'; } @@ -96,19 +106,4 @@ public static function blank(array $font, Mpdf $mpdf) { return !empty($font['colorFormats']) && self::drawable($font['colorFormats']) && !self::sources($font, $mpdf); } - - /** - * The format a font is drawn in: the first of SOURCES that it carries - * - * @param string[] $fontFormats The formats the font carries - * @param bool $color Whether the document may use colour - * - * @return string The format, or '' where the font is drawn without colour - */ - public static function choose(array $fontFormats, $color) - { - $formats = $color ? array_intersect(array_keys(self::SOURCES), $fontFormats) : []; - - return $formats ? reset($formats) : ''; - } } diff --git a/src/Fonts/Color/ColorLine.php b/src/Fonts/Color/ColorLine.php new file mode 100644 index 000000000..dd52cb067 --- /dev/null +++ b/src/Fonts/Color/ColorLine.php @@ -0,0 +1,305 @@ +extend = in_array($extend, [self::REPEAT, self::REFLECT], true) ? $extend : self::PAD; + + // Sorted by offset, and within an offset by the order the font gave them + $order = array_keys($stops); + usort($order, function ($a, $b) use ($stops) { + return $stops[$a][0] === $stops[$b][0] ? $a - $b : ($stops[$a][0] < $stops[$b][0] ? -1 : 1); + }); + + foreach ($order as $i => $key) { + $next = isset($order[$i + 1]) ? $stops[$order[$i + 1]][0] : null; + $previous = $i > 0 ? $stops[$order[$i - 1]][0] : null; + // Of stops sharing an offset, only the first and the last count + if ($stops[$key][0] !== $previous || $stops[$key][0] !== $next) { + $this->stops[] = $stops[$key]; + } + } + + // Stops all at one offset: a hard edge, with a stop 1 either side so the line has a span + if (count($this->stops) === 2 && $this->stops[0][0] === $this->stops[1][0]) { + list($below, $above) = $this->stops; + $this->stops = [[$below[0] - 1, $below[1], $below[2]], $below, $above, [$above[0] + 1, $above[1], $above[2]]]; + $this->extend = self::PAD; + } + } + + /** + * @return float[] [the first stop's offset, the last's] + */ + public function span() + { + $last = end($this->stops); + + return [$this->stops[0][0], $last[0]]; + } + + /** + * The stops taken from the span they lie over onto 0 to 1, and for REPEAT and REFLECT drawn again + * over each whole span from $from to $to, which the gradient's geometry is then taken over instead + * + * @param int $from The first span drawn, 0 being the stops' own + * @param int $to The span after the last drawn + * + * @return array[] Each stop as [offset from 0 to 1, colour, alpha] + */ + public function normalised($from = 0, $to = 1) + { + list($first, $last) = $this->span(); + $stops = []; + foreach ($this->stops as $stop) { + $stops[] = [($stop[0] - $first) / ($last - $first), $stop[1], $stop[2]]; + } + + if ($this->extend === self::PAD) { + return $stops; + } + + $spread = []; + $reversed = array_reverse($stops); + for ($span = $from; $span < $to; $span++) { + $reflect = $this->extend === self::REFLECT && $span % 2 !== 0; + foreach ($reflect ? $reversed : $stops as $stop) { + $offset = $reflect ? 1 - $stop[0] : $stop[0]; + $spread[] = [($span - $from + $offset) / ($to - $from), $stop[1], $stop[2]]; + } + } + + return $spread; + } + + /** + * @return float|null The alpha every stop has, or null where they differ + */ + public function opacity() + { + $alphas = array_unique(array_map(function ($stop) { + return (string) $stop[2]; + }, $this->stops)); + + return count($alphas) === 1 ? (float) reset($alphas) : null; + } + + /** + * The stops between $from and $to, taken onto 0 to 1 + * + * @param array[] $stops Each as [offset from 0 to 1, colour, alpha], in order + * @param float $from + * @param float $to + * + * @return array[] Those stops, with one interpolated at each end + */ + public static function between(array $stops, $from, $to) + { + $between = [array_merge([0.0], self::at($stops, $from))]; + foreach ($stops as $stop) { + if ($stop[0] > $from && $stop[0] < $to) { + $between[] = [($stop[0] - $from) / ($to - $from), $stop[1], $stop[2]]; + } + } + $between[] = array_merge([1.0], self::at($stops, $to)); + + return $between; + } + + /** + * The stops with more added wherever both colour and alpha change between two, so that a colour + * shading and an alpha mask, interpolated separately, together draw premultiplied interpolation. The + * mask needs none of them, its alpha being linear between the stops as they are. + * + * @param array[] $stops Each as [offset, colour, alpha], in order + * + * @return array[] Each as [offset, colour, alpha] + */ + public static function premultiplied(array $stops) + { + if (count($stops) < 2) { + return $stops; + } + + $drawn = [self::edge($stops[0], $stops[1])]; + for ($i = 1; $i < count($stops); $i++) { + $a = $stops[$i - 1]; + $b = $stops[$i]; + $start = self::edge($a, $b); + if ($start != end($drawn)) { + $drawn[] = $start; + } + + $parts = self::parts($a, $b); + for ($part = 1; $part < $parts; $part++) { + $drawn[] = array_merge([$a[0] + ($b[0] - $a[0]) * $part / $parts], self::mix($a, $b, $part / $parts)); + } + $drawn[] = self::edge($b, $a); + } + + return $drawn; + } + + /** + * A stop at one end of a span, in its neighbour's colour where its own alpha is 0 and the neighbour's + * is not: premultiplied, it has no colour of its own + * + * @param array $stop [offset, colour, alpha] + * @param array $neighbour The stop at the span's other end + * + * @return array + */ + private static function edge(array $stop, array $neighbour) + { + return $stop[2] == 0 && $neighbour[2] != 0 ? [$stop[0], $neighbour[1], $stop[2]] : $stop; + } + + /** + * How many parts a span needs for its colours, interpolated alone, to stay within TOLERANCE of + * premultiplied interpolation. The part nearest the lower alpha strays furthest: by the change of + * alpha over it, times the change of the premultiplied colour's weight, times the change of colour, + * over 4. + * + * @param array $a A stop, as [offset, colour, alpha] + * @param array $b The next + * + * @return int 1 where the colour, the alpha or either end's alpha of 0 leaves nothing to add + */ + private static function parts(array $a, array $b) + { + $low = min($a[2], $b[2]); + $high = max($a[2], $b[2]); + $change = 0; + foreach ($a[1] as $channel => $value) { + $change = max($change, abs($b[1][$channel] - $value)); + } + + if ($low <= 0 || $low == $high || $change == 0) { + return 1; + } + + for ($parts = 1; $parts < self::MAX_PARTS; $parts++) { + if (($high - $low) / $parts * $high / ($parts * $low + $high - $low) * $change / 4 <= self::TOLERANCE) { + break; + } + } + + return $parts; + } + + /** + * The colour and alpha at an offset, interpolated between the stops either side of it + * + * @param array[] $stops Each as [offset, colour, alpha], in order + * @param float $offset Where + * + * @return array [colour, alpha], the first stop's before it and the last's after + */ + private static function at(array $stops, $offset) + { + if ($offset <= $stops[0][0]) { + return [$stops[0][1], $stops[0][2]]; + } + + for ($i = 1; $i < count($stops); $i++) { + if ($stops[$i][0] >= $offset) { + return self::mix($stops[$i - 1], $stops[$i], ($offset - $stops[$i - 1][0]) / ($stops[$i][0] - $stops[$i - 1][0])); + } + } + + $last = end($stops); + + return [$last[1], $last[2]]; + } + + /** + * The colour and alpha a fraction of the way from one stop to the next, the colour interpolated + * premultiplied by alpha + * + * @param array $a A stop, as [offset, colour, alpha] + * @param array $b The next + * @param float $t How far from the first to the second, from 0 to 1 + * + * @return array [colour, alpha]: at alpha 0 at one end, the colour is the other end's throughout + */ + private static function mix(array $a, array $b, $t) + { + $alpha = $a[2] + ($b[2] - $a[2]) * $t; + if ($alpha > 0) { + $weights = [$a[2] * (1 - $t) / $alpha, $b[2] * $t / $alpha]; + } elseif ($a[2] == $b[2]) { + $weights = [1 - $t, $t]; + } else { + $weights = $a[2] == 0 ? [0, 1] : [1, 0]; + } + + $colour = []; + foreach ($a[1] as $channel => $value) { + $colour[] = $value * $weights[0] + $b[1][$channel] * $weights[1]; + } + + return [$colour, $alpha]; + } +} diff --git a/src/Fonts/Color/ColrV0Source.php b/src/Fonts/Color/ColrV0Source.php index 438dacaab..e171ee969 100644 --- a/src/Fonts/Color/ColrV0Source.php +++ b/src/Fonts/Color/ColrV0Source.php @@ -8,10 +8,8 @@ * Colour glyphs as layers of outlines, in COLR version 0 with CPAL for their colours: Twemoji's * format, and what a version 1 font carries for renderers that know only this. * - * A colour glyph is a list of layers, each another glyph's outline filled in one colour of a - * palette, drawn bottom to top. A layer whose palette index is 0xFFFF is filled in the colour of the - * text, which is what a layer that sets no colour inside a Type3 glyph is drawn in, as is a layer - * naming a colour the palette does not have. The first palette is drawn. + * A colour glyph is a list of layers, each another glyph's outline filled in a colour of the + * palette, drawn bottom to top - see ColorFontFile::colour() for which colour an index is. * * Every read is checked for coming up short - see FontReader::fieldsAt() - and a glyph whose layers * run past the ones COLR counts draws nothing. @@ -22,7 +20,7 @@ class ColrV0Source implements ColorGlyphSource { - const FOREGROUND = 0xFFFF; + use FillsInColour; /** * @var \Mpdf\Fonts\FileReader @@ -50,9 +48,9 @@ class ColrV0Source implements ColorGlyphSource private $layerCount = 0; /** - * @var int[][] The first palette, each colour as [red, green, blue, alpha] from 0 to 255 + * @var ColorFontFile */ - private $palette = []; + private $file; /** * @param ColorFontFile $file The font @@ -61,6 +59,7 @@ public function __construct(ColorFontFile $file) { $this->reader = $file->reader; $this->outline = $file->outline(); + $this->file = $file; // numBaseGlyphRecords, baseGlyphRecordsOffset, layerRecordsOffset, numLayerRecords $colr = $file->table('COLR')[0]; @@ -77,8 +76,6 @@ public function __construct(ColorFontFile $file) 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]); } /** @@ -104,41 +101,9 @@ public function draw($glyph, GlyphResources $resources) 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); + $content .= $this->filled($this->file->colour($index, 1), $path . 'f', $resources); } 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/ColrV1Source.php b/src/Fonts/Color/ColrV1Source.php new file mode 100644 index 000000000..add2973ee --- /dev/null +++ b/src/Fonts/Color/ColrV1Source.php @@ -0,0 +1,961 @@ + 6, 2 => 5, 4 => 16, 6 => 16, 8 => 12, 10 => 6, 11 => 3, 12 => 7, 14 => 8, 16 => 8, 18 => 12, 20 => 6, 22 => 10, 24 => 6, 26 => 10, 28 => 8, 30 => 12, 32 => 8]; + + /** + * The composite modes that are blend modes, each as PDF names it + */ + const BLEND_MODES = [ + 13 => 'Screen', 14 => 'Overlay', 15 => 'Darken', 16 => 'Lighten', 17 => 'ColorDodge', 18 => 'ColorBurn', 19 => 'HardLight', + 20 => 'SoftLight', 21 => 'Difference', 22 => 'Exclusion', 23 => 'Multiply', 24 => 'Hue', 25 => 'Saturation', 26 => 'Color', + 27 => 'Luminosity', + ]; + + /** + * The Porter-Duff composite modes that draw both the source and the backdrop, each as what is drawn, + * in order: the source or the backdrop, and where it is masked, what by and whether the mask is + * inverted. SRC_IN is the source where the backdrop is, SRC_OUT where it is not. SRC_ATOP, DEST_ATOP + * and XOR sum two such parts, which PDF cannot, so the second is drawn over the first: exact where + * each alpha is 0 or 1, slightly too opaque where both are partial, as at an antialiased edge. CLEAR, + * SRC and DEST draw nothing, the source alone and the backdrop alone. + */ + const COMPOSITES = [ + 3 => [['backdrop'], ['source']], + 4 => [['source'], ['backdrop']], + 5 => [['source', 'backdrop', false]], + 6 => [['backdrop', 'source', false]], + 7 => [['source', 'backdrop', true]], + 8 => [['backdrop', 'source', true]], + 9 => [['backdrop', 'source', true], ['source', 'backdrop', false]], + 10 => [['source', 'backdrop', true], ['backdrop', 'source', false]], + 11 => [['source', 'backdrop', true], ['backdrop', 'source', true]], + ]; + + /** + * The composite mode that adds the source to the backdrop, which PDF has no way to draw + */ + const PLUS = 12; + + const IDENTITY = [1, 0, 0, 1, 0, 0]; + + /** + * @var ColorFontFile + */ + private $file; + + /** + * @var int Where COLR ends, past which nothing is read + */ + private $end = 0; + + /** + * @var int[] Base glyph id => where its root paint is + */ + private $baseGlyphs = []; + + /** + * @var int Where the LayerList starts + */ + private $layers = 0; + + /** + * @var int How many layers the LayerList has + */ + private $layerCount = 0; + + /** + * @var int[][] Each clip as [first glyph id, last glyph id, where its ClipBox is] + */ + private $clips = []; + + /** + * @var int[] The font's bounding box, [xMin, yMin, xMax, yMax], for a glyph with no clip box + */ + private $bbox; + + /** + * @var string[] Each paint's bytes, by where it is, read once however many glyphs share it: as many + * as the longest paint's fields take, or as COLR has left + */ + private $paints = []; + + /** + * @var GlyphResources What the glyph being drawn registers its shadings, groups and masks with + */ + private $resources; + + /** + * @var int The glyph being drawn, for a warning + */ + private $glyph = 0; + + /** + * @var float[] The clip box of the glyph being drawn, in glyph space + */ + private $box = []; + + /** + * @param ColorFontFile $file The font + */ + public function __construct(ColorFontFile $file) + { + $this->file = $file; + + $head = $file->table('head')[0]; + $bbox = $file->reader->fieldsAt($head + 36, 8, 'n4'); + $this->bbox = $bbox === null ? [0, 0, 0, 0] : array_map([__CLASS__, 'signed'], $bbox); + + list($colr, $length) = $file->table('COLR'); + $this->end = $colr + $length; + + // version, then past the version 0 fields, baseGlyphListOffset, layerListOffset, clipListOffset + $header = $this->fields($colr, 34, 'nversion/x12/Nlist/Nlayers/Nclips'); + if ($header === null || $header[0] < 1) { + return; + } + + list(, $list, $layers, $clips) = $header; + + // Each record is a glyph id and a 32-bit offset, read as three 16-bit numbers + $count = $list ? $this->fields($colr + $list, 4, 'N') : null; + $records = $count === null ? null : $this->fields($colr + $list + 4, $count[0] * 6, 'n*'); + for ($i = 0; $records !== null && $i < count($records); $i += 3) { + $this->baseGlyphs[$records[$i]] = $colr + $list + ($records[$i + 1] << 16 | $records[$i + 2]); + } + + $layerCount = $layers ? $this->fields($colr + $layers, 4, 'N') : null; + if ($layerCount !== null) { + $this->layers = $colr + $layers; + $this->layerCount = $layerCount[0]; + } + + // format, of which only 1 is defined, numClips, then each clip's first and last glyph and a 24-bit + // offset + $clipList = $clips ? $this->fields($colr + $clips, 5, 'Cformat/Ncount') : null; + $clipCount = $clipList !== null && $clipList[0] === 1 ? $clipList[1] : 0; + $clipRecords = $clipCount ? $this->fields($colr + $clips + 5, $clipCount * 7, 'a*') : null; + for ($i = 0; $clipRecords !== null && $i < $clipCount; $i++) { + $record = unpack('nfirst/nlast/Chigh/nlow', substr($clipRecords[0], 7 * $i, 7)); + $this->clips[] = [$record['first'], $record['last'], $colr + $clips + ($record['high'] << 16 | $record['low'])]; + } + } + + /** + * @inheritdoc + */ + public function draw($glyph, GlyphResources $resources) + { + if (!isset($this->baseGlyphs[$glyph])) { + return null; + } + + $this->resources = $resources; + $this->glyph = $glyph; + $this->box = $this->clipBox($glyph); + + $content = $this->paint($this->baseGlyphs[$glyph], self::IDENTITY, []); + if ($content === '') { + return null; + } + + return 'q ' . $this->rectangle($this->box) . " re W n\n" . $content . "Q\n"; + } + + /** + * @param int $offset Where the paint is + * @param float[] $matrix What takes the paint's space to glyph space + * @param true[] $path The paints that led here, by where they are, so a loop is seen + * + * @return string Content drawing the paint, in its own space + */ + private function paint($offset, array $matrix, array $path) + { + if (isset($path[$offset]) || count($path) >= self::MAX_DEPTH) { + $this->warn('a paint graph that loops or nests too deep, which is drawn only so far'); + + return ''; + } + $path[$offset] = true; + + $paint = $this->paintAt($offset); + if ($paint === '') { + return ''; + } + + // A Var paint is read as the paint it varies: the same fields, then a varIndexBase + $format = ord($paint[0]); + $var = $format % 2 === 1 && $format > 2 && $format < 32 && $format !== 11; + $base = $var ? $format - 1 : $format; + + if (!array_key_exists($base, self::SIZES)) { + $this->warn(sprintf('a paint of format %d, which is not in the spec', $format)); + + return ''; + } + + if (strlen($paint) < self::SIZES[$base]) { + return ''; + } + + switch ($base) { + case 1: + return $this->layers(FontReader::uint32(substr($paint, 2, 4)), ord($paint[1]), $matrix, $path); + + case 2: + return $this->fill($this->solid($offset), $matrix); + + case 4: + case 6: + case 8: + return $this->gradient($base, $paint, self::child($offset, $paint, 1), $var, $matrix); + + case 10: + return $this->clipped(self::uint16($paint, 4), self::child($offset, $paint, 1), $matrix, $path); + + case 11: + $glyph = self::uint16($paint, 1); + + return isset($this->baseGlyphs[$glyph]) ? $this->paint($this->baseGlyphs[$glyph], $matrix, $path) : ''; + + case 32: + return $this->composite($offset, $paint, $matrix, $path); + } + + $transform = $this->transform($base, $paint, $offset); + $child = self::child($offset, $paint, 1); + if ($transform === null || $child === null) { + return ''; + } + + $content = $this->paint($child, self::multiply($transform, $matrix), $path); + + return $content === '' ? '' : sprintf("q %s cm\n", self::numbers($transform)) . $content . "Q\n"; + } + + /** + * @param int $first The first layer, in the LayerList + * @param int $count How many layers + * @param float[] $matrix What takes the paints' space to glyph space + * @param true[] $path The paints that led here + * + * @return string Content drawing each layer, bottom to top, or nothing where they run past the list + */ + private function layers($first, $count, array $matrix, array $path) + { + $offsets = $first + $count > $this->layerCount ? null : $this->fields($this->layers + 4 + $first * 4, $count * 4, 'N*'); + + $content = ''; + foreach ($offsets === null ? [] : $offsets as $offset) { + $content .= $this->paint($this->layers + $offset, $matrix, $path); + } + + return $content; + } + + /** + * A PaintGlyph: what its child paints, inside the glyph's outline + * + * @param int $glyph The glyph whose outline clips + * @param int|null $child Where the child paint is + * @param float[] $matrix What takes the paint's space to glyph space + * @param true[] $path The paints that led here + * + * @return string + */ + private function clipped($glyph, $child, array $matrix, array $path) + { + $outline = $this->file->outline()->path($glyph); + if ($outline === '' || $child === null) { + return ''; + } + + // A solid colour fills the outline, as a version 0 layer does, rather than a box clipped to it + $solid = $this->solid($child); + if ($solid !== null) { + return $this->filled($solid, $outline . 'f', $this->resources); + } + + $content = $this->paint($child, $matrix, $path); + + return $content === '' ? '' : "q\n" . $outline . "W n\n" . $content . "Q\n"; + } + + /** + * @param int $offset Where the PaintComposite is + * @param string $paint Its bytes + * @param float[] $matrix What takes the paint's space to glyph space + * @param true[] $path The paints that led here + * + * @return string The source composited onto the backdrop + */ + private function composite($offset, $paint, array $matrix, array $path) + { + $box = $this->boxIn($matrix); + if ($box === null) { + return ''; + } + + $mode = ord($paint[4]); + if ($mode === self::PLUS) { + $this->warn('composite mode PLUS, which is drawn as the source over the backdrop'); + $mode = 3; + } elseif ($mode > 2 && !array_key_exists($mode, self::BLEND_MODES) && !array_key_exists($mode, self::COMPOSITES)) { + // The spec draws a mode it does not define as CLEAR + $this->warn(sprintf('composite mode %d, which is not in the spec and draws nothing', $mode)); + $mode = 0; + } + + // CLEAR, SRC and DEST: nothing, the source alone, the backdrop alone + if ($mode <= 2) { + $child = $mode === 0 ? null : self::child($offset, $paint, $mode === 1 ? 1 : 5); + + return $child === null ? '' : $this->paint($child, $matrix, $path); + } + + $blend = array_key_exists($mode, self::BLEND_MODES); + + $drawn = ['source' => '', 'backdrop' => '']; + foreach ([1 => 'source', 5 => 'backdrop'] as $at => $part) { + $child = self::child($offset, $paint, $at); + if ($child !== null) { + $drawn[$part] = $this->paint($child, $matrix, $path); + } + } + + // The source blended with the backdrop alone, then the two drawn together on what is below + if ($blend) { + $content = $drawn['backdrop']; + if ($drawn['source'] !== '') { + $content .= sprintf("q %s %s Do Q\n", $this->resources->blend(self::BLEND_MODES[$mode]), $this->resources->group($drawn['source'], $box)); + } + + return $content === '' ? '' : $this->resources->group($content, $box, true) . " Do\n"; + } + + $content = ''; + foreach (self::COMPOSITES[$mode] as $part) { + $content .= isset($part[1]) ? $this->masked($drawn[$part[0]], $drawn[$part[1]], $part[2], $box) : $drawn[$part[0]]; + } + + return $content; + } + + /** + * @param string $content What is drawn + * @param string $mask What it is drawn only where, by alpha + * @param bool $inverted Whether it is drawn only where the mask is not, instead + * @param float[] $box The area drawn, in the space it is drawn in + * + * @return string + */ + private function masked($content, $mask, $inverted, array $box) + { + if ($content === '' || $mask === '') { + return $inverted ? $content : ''; + } + + return sprintf("q %s %s Do Q\n", $this->resources->softMask($mask, $box, false, $inverted), $this->resources->group($content, $box)); + } + + /** + * @param int $format The gradient's paint format: 4, linear, 6, radial or 8, sweep + * @param string $paint Its bytes + * @param int|null $line Where its ColorLine is + * @param bool $var Whether it is a Var paint, whose ColorLine is a VarColorLine + * @param float[] $matrix What takes the paint's space to glyph space + * + * @return string Content filling the clip with the gradient + */ + private function gradient($format, $paint, $line, $var, array $matrix) + { + $line = $line === null ? null : $this->colorLine($line, $var); + $box = $this->boxIn($matrix); + if ($line === null || !$line->stops || $box === null) { + return ''; + } + + if ($format === 8) { + $this->warn('a sweep gradient, which is drawn in the colour of its middle stop'); + } + + if (count($line->stops) === 1 || $format === 8) { + $stop = $line->stops[(int) (count($line->stops) / 2)]; + + return $this->fill([$stop[1], $stop[2]], $matrix); + } + + list($first, $last) = $line->span(); + + $values = []; + for ($i = 4; $i < 16; $i += 2) { + $values[] = self::int16At($paint, $i); + } + + if ($format === 4) { + $geometry = $this->linear($values, $first, $last); + } else { + // The radii are unsigned. Two circles the same are ill-formed. + $values[2] = self::uint16($paint, 8); + $values[5] = self::uint16($paint, 14); + $same = $values[0] === $values[3] && $values[1] === $values[4] && $values[2] === $values[5]; + $geometry = $same ? null : self::along($values, $first, $last); + } + + if ($geometry === null) { + return ''; + } + + list($from, $to) = $line->extend === ColorLine::PAD ? [0, 1] : $this->spans($geometry, $box); + $coords = self::along($geometry, $from, $to); + $stops = $line->normalised($from, $to); + + $cut = self::nonNegative($coords, $stops); + if ($cut === null) { + return ''; + } + list($coords, $stops) = $cut; + + $colours = []; + foreach (ColorLine::premultiplied($stops) as $stop) { + $colours[] = [$stop[0], $stop[1]]; + } + $alphas = []; + foreach ($stops as $stop) { + $alphas[] = [$stop[0], [$stop[2]]]; + } + + $content = $this->resources->shading(['coords' => $coords, 'stops' => $colours]) . " sh\n"; + $opacity = $line->opacity(); + if ($opacity === null) { + $mask = $this->resources->shading(['coords' => $coords, 'stops' => $alphas]) . " sh\n"; + + return sprintf("q %s\n", $this->resources->softMask($mask, $box, true)) . $content . "Q\n"; + } + + if ($opacity <= 0) { + return ''; + } + + return $opacity < 1 ? sprintf("q %s\n", $this->resources->alpha($opacity)) . $content . "Q\n" : $content; + } + + /** + * A linear gradient's three points folded into the two of an axial shading, over the span of its + * stops + * + * @param int[] $values x0, y0, x1, y1, x2, y2 + * @param float $first The first stop's offset + * @param float $last The last stop's offset + * + * @return float[]|null [x0, y0, x1, y1] for offsets 0 and 1, or null where the gradient is + * ill-formed: a point on the first, or the three in a line + */ + private function linear(array $values, $first, $last) + { + list($x0, $y0, $x1, $y1, $x2, $y2) = $values; + + // The second point moved onto the line through the first perpendicular to the first and third + $normal = [$y2 - $y0, $x0 - $x2]; + $squared = $normal[0] * $normal[0] + $normal[1] * $normal[1]; + $along = ($x1 - $x0) * $normal[0] + ($y1 - $y0) * $normal[1]; + if ($squared == 0 || $along == 0) { + return null; + } + + $x3 = $x0 + $normal[0] * $along / $squared; + $y3 = $y0 + $normal[1] * $along / $squared; + + return self::along([$x0, $y0, $x3, $y3], $first, $last); + } + + /** + * The whole spans of a repeated or reflected gradient's stops needed to cover an area + * + * @param float[] $geometry The shading's coordinates for offsets 0 and 1: two points, or two circles + * @param float[] $box The area, in the gradient's space + * + * @return int[] [the first span, the span after the last], 0 being the stops' own + */ + private function spans(array $geometry, array $box) + { + $corners = [[$box[0], $box[1]], [$box[0], $box[3]], [$box[2], $box[1]], [$box[2], $box[3]]]; + + if (count($geometry) === 4) { + // Each corner's offset along the axis + list($x0, $y0, $x1, $y1) = $geometry; + $dx = $x1 - $x0; + $dy = $y1 - $y0; + $offsets = []; + foreach ($corners as $corner) { + $offsets[] = (($corner[0] - $x0) * $dx + ($corner[1] - $y0) * $dy) / ($dx * $dx + $dy * $dy); + } + + return [(int) max(-self::MAX_REPEATS, floor(min($offsets))), (int) min(self::MAX_REPEATS, ceil(max($offsets)))]; + } + + // Out from the stops each way until a circle takes in every corner, or shrinks to nothing + $spans = []; + foreach ([-1, 1] as $direction) { + $span = $direction < 0 ? 0 : 1; + while (abs($span) < self::MAX_REPEATS) { + $circle = self::along($geometry, $span, $span); + if ($circle[2] <= 0 || self::covers($circle, $corners)) { + break; + } + $span += $direction; + } + $spans[] = $span; + } + + return $spans; + } + + /** + * @param float[] $circle [x, y, r] + * @param float[][] $corners + * + * @return bool Whether every corner lies within the circle + */ + private static function covers(array $circle, array $corners) + { + foreach ($corners as $corner) { + if (hypot($corner[0] - $circle[0], $corner[1] - $circle[1]) > $circle[2]) { + return false; + } + } + + return true; + } + + /** + * A shading's coordinates moved to other offsets along it + * + * @param float[] $geometry Its coordinates for offsets 0 and 1: two points, or two circles + * @param float $from The offset the new coordinates start at + * @param float $to The offset they end at + * + * @return float[] The coordinates for offsets $from and $to; a radius may come out below 0 + */ + private static function along(array $geometry, $from, $to) + { + $half = count($geometry) / 2; + $start = []; + $end = []; + for ($i = 0; $i < $half; $i++) { + $start[] = $geometry[$i] + $from * ($geometry[$i + $half] - $geometry[$i]); + $end[] = $geometry[$i] + $to * ($geometry[$i + $half] - $geometry[$i]); + } + + return array_merge($start, $end); + } + + /** + * A radial shading cut to where its radius is not below 0, all a PDF shading can draw, its stops cut + * to match. An axial shading is left as it is. + * + * @param float[] $coords Two points, or two circles as [x0, y0, r0, x1, y1, r1] + * @param array[] $stops The stops from the first to the second, from 0 to 1 + * + * @return array|null [coords, stops], or null where the radius is nowhere above 0 + */ + private static function nonNegative(array $coords, array $stops) + { + if (count($coords) === 4 || ($coords[2] >= 0 && $coords[5] >= 0)) { + return [$coords, $stops]; + } + + list(, , $r0, , , $r1) = $coords; + if ($r0 <= 0 && $r1 <= 0) { + return null; + } + + $tip = $r0 / ($r0 - $r1); + list($from, $to) = $r0 < 0 ? [$tip, 1] : [0, $tip]; + $cut = self::along($coords, $from, $to); + $cut[$r0 < 0 ? 2 : 5] = 0; + + return [$cut, ColorLine::between($stops, $from, $to)]; + } + + /** + * @param int $offset Where the ColorLine is + * @param bool $var Whether it is a VarColorLine, whose stops each end in a varIndexBase + * + * @return ColorLine|null + */ + private function colorLine($offset, $var) + { + $header = $this->fields($offset, 3, 'Cextend/ncount'); + $size = $var ? 10 : 6; + $data = $header === null ? null : $this->fields($offset + 3, $header[1] * $size, 'a*'); + if ($data === null) { + return null; + } + + $stops = []; + for ($i = 0; $i < $header[1]; $i++) { + $stop = unpack('noffset/nindex/nalpha', substr($data[0], $i * $size, 6)); + list($colour, $alpha) = $this->file->colour($stop['index'], self::signed($stop['alpha']) / 16384); + // A stop in the colour of the text is black: a shading cannot take its colour from the text + $stops[] = [self::signed($stop['offset']) / 16384, $colour === null ? [0, 0, 0] : $colour, $alpha]; + } + + return new ColorLine($header[0], $stops); + } + + /** + * @param int $format A transform's paint format, from 12 to 30 + * @param string $paint Its bytes + * @param int $offset Where it is + * + * @return float[]|null The transform as a PDF matrix, or null where it cannot be read + */ + private function transform($format, $paint, $offset) + { + switch ($format) { + case 12: + // An Affine2x3 of 16.16 fixed numbers, in the order a PDF matrix takes them + $affine = self::child($offset, $paint, 4); + $fixed = $affine === null ? null : $this->fields($affine, 24, 'N6'); + if ($fixed === null) { + return null; + } + + return array_map(function ($value) { + return ($value >= 0x80000000 ? $value - 0x100000000 : $value) / 65536; + }, $fixed); + + case 14: + return [1, 0, 0, 1, self::int16At($paint, 4), self::int16At($paint, 6)]; + + case 16: + case 18: + $matrix = [self::f2dot14($paint, 4), 0, 0, self::f2dot14($paint, 6), 0, 0]; + break; + + case 20: + case 22: + $scale = self::f2dot14($paint, 4); + $matrix = [$scale, 0, 0, $scale, 0, 0]; + break; + + case 24: + case 26: + // Counter-clockwise, in half turns + $angle = self::f2dot14($paint, 4) * M_PI; + $matrix = [cos($angle), sin($angle), -sin($angle), cos($angle), 0, 0]; + break; + + case 28: + case 30: + // The x angle is clockwise and the y counter-clockwise, each in half turns + $matrix = [1, tan(self::f2dot14($paint, 6) * M_PI), -tan(self::f2dot14($paint, 4) * M_PI), 1, 0, 0]; + break; + + default: + return null; + } + + // The formats about a centre end in the centre's x and y + return in_array($format, [18, 22, 26, 30], true) ? self::around($matrix, $paint, self::SIZES[$format] - 4) : $matrix; + } + + /** + * @param float[] $matrix A transform about the origin + * @param string $paint The paint's bytes + * @param int $at Where in them its centre's x and y are + * + * @return float[] The transform about the centre instead + */ + private static function around(array $matrix, $paint, $at) + { + $x = self::int16At($paint, $at); + $y = self::int16At($paint, $at + 2); + + $matrix[4] = $x - $matrix[0] * $x - $matrix[2] * $y; + $matrix[5] = $y - $matrix[1] * $x - $matrix[3] * $y; + + return $matrix; + } + + /** + * @param array $colour As ColorFontFile::colour() gives it + * @param float[] $matrix What takes the paint's space to glyph space + * + * @return string Content filling the clip in the colour + */ + private function fill(array $colour, array $matrix) + { + $box = $this->boxIn($matrix); + if ($box === null) { + return ''; + } + + return $this->filled($colour, $this->rectangle($box) . ' re f', $this->resources); + } + + /** + * @param float[] $box [xMin, yMin, xMax, yMax] + * + * @return string The box's x, y, width and height, as re takes them + */ + private function rectangle(array $box) + { + return self::numbers([$box[0], $box[1], $box[2] - $box[0], $box[3] - $box[1]]); + } + + /** + * @param int $offset Where a paint is + * + * @return array|null Its colour, as ColorFontFile::colour() gives it, where it is a PaintSolid or a + * PaintVarSolid; null where it is anything else + */ + private function solid($offset) + { + $paint = $this->paintAt($offset); + if (strlen($paint) < self::SIZES[2] || (ord($paint[0]) !== 2 && ord($paint[0]) !== 3)) { + return null; + } + + return $this->file->colour(self::uint16($paint, 1), self::f2dot14($paint, 3)); + } + + /** + * @param int $offset Where a paint is + * + * @return string Its bytes, as many as the longest paint's fields take or as COLR has left, or none + * where it lies outside COLR + */ + private function paintAt($offset) + { + if (!isset($this->paints[$offset])) { + $length = min(max(self::SIZES), $this->end - $offset); + $bytes = $length > 0 ? $this->fields($offset, $length, 'a*') : null; + $this->paints[$offset] = $bytes ? $bytes[0] : ''; + } + + return $this->paints[$offset]; + } + + /** + * @param int $glyph + * + * @return float[] The glyph's clip box, or the font's bounding box where it has none + */ + private function clipBox($glyph) + { + foreach ($this->clips as $clip) { + if ($glyph >= $clip[0] && $glyph <= $clip[1]) { + // format, xMin, yMin, xMax, yMax: format 2 is the same, with a varIndexBase after + $box = $this->fields($clip[2], 9, 'Cformat/n4'); + if ($box !== null && ($box[0] === 1 || $box[0] === 2)) { + return array_map([__CLASS__, 'signed'], array_slice($box, 1)); + } + } + } + + return $this->bbox; + } + + /** + * @param float[] $matrix What takes a paint's space to glyph space + * + * @return float[]|null The clip box in the paint's space, as [xMin, yMin, xMax, yMax] around it, or + * null where the space is flattened to a line or a point, and nothing in it + * shows + */ + private function boxIn(array $matrix) + { + list($a, $b, $c, $d, $e, $f) = $matrix; + $determinant = $a * $d - $b * $c; + if (abs($determinant) < 1e-9) { + return null; + } + + $inverse = [$d / $determinant, -$b / $determinant, -$c / $determinant, $a / $determinant, ($c * $f - $d * $e) / $determinant, ($b * $e - $a * $f) / $determinant]; + list($xMin, $yMin, $xMax, $yMax) = $this->box; + $xs = []; + $ys = []; + foreach ([[$xMin, $yMin], [$xMin, $yMax], [$xMax, $yMin], [$xMax, $yMax]] as $corner) { + $xs[] = $inverse[0] * $corner[0] + $inverse[2] * $corner[1] + $inverse[4]; + $ys[] = $inverse[1] * $corner[0] + $inverse[3] * $corner[1] + $inverse[5]; + } + + return [min($xs), min($ys), max($xs), max($ys)]; + } + + /** + * @param float[] $first A transform + * @param float[] $second Another + * + * @return float[] The transform doing the first, then the second + */ + private static function multiply(array $first, array $second) + { + list($a, $b, $c, $d, $e, $f) = $first; + list($a2, $b2, $c2, $d2, $e2, $f2) = $second; + + return [ + $a * $a2 + $b * $c2, + $a * $b2 + $b * $d2, + $c * $a2 + $d * $c2, + $c * $b2 + $d * $d2, + $e * $a2 + $f * $c2 + $e2, + $e * $b2 + $f * $d2 + $f2, + ]; + } + + /** + * @param int $offset Where a paint is + * @param string $paint Its bytes + * @param int $at Where in them an Offset24 to a subtable is + * + * @return int|null Where the subtable is, or null where the offset is 0, which is no subtable + */ + private static function child($offset, $paint, $at) + { + $relative = ord($paint[$at]) << 16 | self::uint16($paint, $at + 1); + + return $relative === 0 ? null : $offset + $relative; + } + + /** + * FontReader::fieldsAt(), which also reads nothing past the end of COLR + * + * @param int $position From the start of the font + * @param int $length The bytes the fields take + * @param string $format How to unpack() them + * + * @return array|null + */ + private function fields($position, $length, $format) + { + return $position + $length > $this->end ? null : $this->file->reader->fieldsAt($position, $length, $format); + } + + /** + * Logs part of a glyph that is not drawn as the font asks + * + * @param string $what What it is + */ + private function warn($what) + { + $this->file->logger->warning(sprintf('Colour glyph %d has %s', $this->glyph, $what), ['context' => LogContext::FONTS]); + } + + /** + * @param float[] $values + * + * @return string The values as content writes them, spaced + */ + private static function numbers(array $values) + { + return implode(' ', array_map([__CLASS__, 'number'], $values)); + } + + /** + * @param float $value + * + * @return string The value to five places and no more than it needs + */ + private static function number($value) + { + $number = rtrim(rtrim(sprintf('%.5F', $value), '0'), '.'); + + return $number === '-0' ? '0' : $number; + } + + /** + * @param int $value A uint16 + * + * @return int The same bits as an int16 + */ + private static function signed($value) + { + return $value >= 0x8000 ? $value - 0x10000 : $value; + } + + /** + * @return int The int16 at a place in some bytes + */ + private static function int16At($bytes, $at) + { + return FontReader::int16(substr($bytes, $at, 2)); + } + + /** + * @return int The uint16 at a place in some bytes + */ + private static function uint16($bytes, $at) + { + return ord($bytes[$at]) << 8 | ord($bytes[$at + 1]); + } + + /** + * @return float The F2DOT14 at a place in some bytes + */ + private static function f2dot14($bytes, $at) + { + return self::int16At($bytes, $at) / 16384; + } +} diff --git a/src/Fonts/Color/FillsInColour.php b/src/Fonts/Color/FillsInColour.php new file mode 100644 index 000000000..61b5c6d07 --- /dev/null +++ b/src/Fonts/Color/FillsInColour.php @@ -0,0 +1,37 @@ +alpha($alpha); + } + if ($rgb !== null) { + $operators[] = vsprintf('%.3F %.3F %.3F rg', $rgb); + } + + return $operators ? 'q ' . implode(' ', $operators) . "\n" . $fill . "\nQ\n" : $fill . "\n"; + } +} diff --git a/src/Fonts/Color/GlyphResources.php b/src/Fonts/Color/GlyphResources.php index 6bd6b110a..f958d822a 100644 --- a/src/Fonts/Color/GlyphResources.php +++ b/src/Fonts/Color/GlyphResources.php @@ -6,6 +6,9 @@ * 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. + * + * A group or soft mask is drawn with the same resource dictionary as the glyph, so its content can use + * whatever the glyph's can. */ interface GlyphResources { @@ -24,4 +27,42 @@ public function image($data); * @return string Content setting fills to that opacity, e.g. '/GS2 gs' */ public function alpha($opacity); + + /** + * @param string $mode A PDF blend mode, e.g. 'SoftLight' + * + * @return string Content setting what is painted to blend with what is under it so, e.g. '/GS3 gs' + */ + public function blend($mode); + + /** + * @param array $shading 'coords' => the shading's /Coords, four for an axial shading's two points or + * six for a radial one's two circles; 'stops' => each [offset from 0 to 1, + * colour], the colour [red, green, blue] or [grey], from 0 to 1, the first + * stop at 0 and the last at 1 + * + * @return string The name the content paints it by with sh, e.g. '/Sh2' + */ + public function shading(array $shading); + + /** + * @param string $content What the group draws + * @param float[] $box [xMin, yMin, xMax, yMax] it is drawn within, in the space it is drawn in + * @param bool $isolated Whether what it draws is composited only with itself, and the result + * then with what is under it, rather than each thing it draws with what + * is under it + * + * @return string The name the content draws the group by with Do, e.g. '/Fx4' + */ + public function group($content, array $box, $isolated = false); + + /** + * @param string $content What the mask is drawn from + * @param float[] $box [xMin, yMin, xMax, yMax] it is drawn within, in the space it is drawn in + * @param bool $luminosity Whether the mask is the content's brightness, rather than its alpha + * @param bool $inverted Whether what the content covers is masked out, rather than let through + * + * @return string Content masking what is painted after it so, e.g. '/SM1 gs' + */ + public function softMask($content, array $box, $luminosity = false, $inverted = false); } diff --git a/src/Fonts/FontSubstitution.php b/src/Fonts/FontSubstitution.php index bf13a4d26..618cff365 100644 --- a/src/Fonts/FontSubstitution.php +++ b/src/Fonts/FontSubstitution.php @@ -60,7 +60,7 @@ public function emojiWantsAnotherFont(array $emoji, $presentation) return true; } - if ($presentation === Emoji::PRESENTATION_DEFAULT || (ColorFormats::drawn($this->mpdf->CurrentFont, $this->mpdf) !== '') === ($presentation === Emoji::PRESENTATION_EMOJI)) { + if ($presentation === Emoji::PRESENTATION_DEFAULT || ColorFormats::drawsInColor($this->mpdf->CurrentFont, $this->mpdf) === ($presentation === Emoji::PRESENTATION_EMOJI)) { return false; } @@ -193,7 +193,7 @@ private function isColorFont($family) $this->loadFont($family); } - return ColorFormats::drawn($this->mpdf->fonts[$family], $this->mpdf) !== ''; + return ColorFormats::drawsInColor($this->mpdf->fonts[$family], $this->mpdf); } /** diff --git a/src/Fonts/MetricsGenerator.php b/src/Fonts/MetricsGenerator.php index cc3271e79..f614f1738 100644 --- a/src/Fonts/MetricsGenerator.php +++ b/src/Fonts/MetricsGenerator.php @@ -14,9 +14,10 @@ class MetricsGenerator * Regeneration is otherwise triggered only by the font file's size changing, so a release that * changes what a cache file *means* — the unit an offset is measured in, which table a blob * 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. + * whenever that happens, and when ColorFormats::SOURCES gains a format. Mpdf::AddFont() compares it + * and regenerates on a mismatch. */ - const CACHE_FORMAT = 7; + const CACHE_FORMAT = 8; /** * Whether cached metrics are in the shape this release writes - see CACHE_FORMAT diff --git a/src/Writer/Type3FontWriter.php b/src/Writer/Type3FontWriter.php index 62457cfb3..6e2fe8070 100644 --- a/src/Writer/Type3FontWriter.php +++ b/src/Writer/Type3FontWriter.php @@ -29,10 +29,11 @@ * 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 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(). + * Each subset has a resource dictionary of its own, listing the images, graphics states, shadings, + * groups and soft masks 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(). A group or soft mask draws with its subset's dictionary too. * * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf 9.6.5 */ @@ -77,17 +78,31 @@ class Type3FontWriter implements GlyphResources private $undrawable = []; /** - * @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 + * @var array[] What the glyphs of the subset being written draw with, each once: 'images', keys in + * Mpdf::$images; 'states', numbers in Mpdf::$extgstates; 'shadings', keys in $shadings; + * 'forms', each group by a key of its content, as ['name', 'content', 'box', 'group' => + * what its /Group dictionary adds]; 'masks', each soft mask by a key, as ['name', 'form' + * => the name of the group it is drawn from, 'luminosity', 'inverted'] */ private $drawn = []; /** - * @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 + * @var array[][] 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 = []; + /** + * @var array[] Each shading the glyphs draw, by a key of it, written once for every subset: ['name', + * 'shading' as GlyphResources::shading() takes it, 'n' once written] + */ + private $shadings = []; + + /** + * @var int How many shadings, groups and soft masks have been named, so each is named apart + */ + private $named = 0; + /** * @param Mpdf $mpdf The document, whose fonts, images and resources are written to * @param BaseWriter $writer Writes the objects @@ -223,12 +238,92 @@ public function image($data) */ public function alpha($opacity) { - $state = $this->mpdf->AddExtGState(['BM' => '/Normal', 'ca' => $opacity]); + return $this->state(['BM' => '/Normal', 'ca' => $opacity]); + } + + /** + * Registers a graphics state blending what is painted with what is under it, once however many + * glyphs blend so + * + * @param string $mode A PDF blend mode, e.g. 'SoftLight' + * + * @return string Content setting it, e.g. '/GS3 gs' + */ + public function blend($mode) + { + return $this->state(['BM' => '/' . $mode]); + } + + /** + * @param array $parameters The graphics state's entries, as Mpdf::AddExtGState() takes them + * + * @return string Content setting it, e.g. '/GS3 gs' + */ + private function state(array $parameters) + { + $state = $this->mpdf->AddExtGState($parameters); $this->drawn['states'][$state] = $state; return sprintf('/GS%d gs', $state); } + /** + * @inheritdoc + */ + public function shading(array $shading) + { + $key = md5(serialize($shading)); + if (!isset($this->shadings[$key])) { + $this->shadings[$key] = ['name' => 'Sh' . ++$this->named, 'shading' => $shading]; + } + $this->drawn['shadings'][$key] = $key; + + return '/' . $this->shadings[$key]['name']; + } + + /** + * @inheritdoc + */ + public function group($content, array $box, $isolated = false) + { + return '/' . $this->form($content, $box, $isolated ? ' /I true' : ''); + } + + /** + * @inheritdoc + */ + public function softMask($content, array $box, $luminosity = false, $inverted = false) + { + // A mask of brightness is drawn in grey + $form = $this->form($content, $box, $luminosity ? ' /CS /DeviceGray' : ''); + $key = $form . ($luminosity ? '-luminosity' : '-alpha') . ($inverted ? '-inverted' : ''); + if (!isset($this->drawn['masks'][$key])) { + $this->drawn['masks'][$key] = ['name' => 'SM' . ++$this->named, 'form' => $form, 'luminosity' => $luminosity, 'inverted' => $inverted]; + } + + return '/' . $this->drawn['masks'][$key]['name'] . ' gs'; + } + + /** + * Registers a transparency group on the subset being written, once however many of its glyphs draw + * the same + * + * @param string $content What the group draws + * @param float[] $box [xMin, yMin, xMax, yMax] it is drawn within + * @param string $group What its /Group dictionary adds to /S /Transparency + * + * @return string Its name, e.g. 'Fx4' + */ + private function form($content, array $box, $group) + { + $key = md5($content . serialize($box) . $group); + if (!isset($this->drawn['forms'][$key])) { + $this->drawn['forms'][$key] = ['name' => 'Fx' . ++$this->named, 'content' => $content, 'box' => $box, 'group' => $group]; + } + + return $this->drawn['forms'][$key]['name']; + } + /** * @throws \Mpdf\MpdfException Where the JPEG's size and colour space cannot be read * @@ -283,8 +378,9 @@ 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, - * and writes the graphics states the glyphs registered after the document's were written. + * it, with the shadings, groups and soft masks it names. 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() { @@ -294,24 +390,85 @@ public function writeResources() $this->mpdf->_putextgstates(); + foreach ($this->shadings as $key => $shading) { + if (!isset($shading['n'])) { + $this->shadings[$key]['n'] = $this->writeObject($this->shadingDictionary($shading['shading'])); + } + } + foreach ($this->resources as $object => $drawn) { - $images = []; + $objects = ['XObject' => [], 'ExtGState' => [], 'Shading' => []]; foreach ($drawn['images'] as $key) { - $images['I' . $this->mpdf->images[$key]['i']] = $this->mpdf->images[$key]['n']; + $objects['XObject']['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']; + $objects['ExtGState']['GS' . $state] = $this->mpdf->extgstates[$state]['n']; + } + foreach ($drawn['shadings'] as $key) { + $objects['Shading'][$this->shadings[$key]['name']] = $this->shadings[$key]['n']; + } + + // Each group draws with this dictionary, and each mask from a group + foreach ($drawn['forms'] as $form) { + list($xMin, $yMin, $xMax, $yMax) = $form['box']; + $this->writeStream(sprintf('/Type /XObject /Subtype /Form /BBox [%.3F %.3F %.3F %.3F] /Group <> /Resources %d 0 R', $xMin, $yMin, $xMax, $yMax, $form['group'], $object), $form['content']); + $objects['XObject'][$form['name']] = $this->mpdf->n; + } + foreach ($drawn['masks'] as $mask) { + // An inverted mask lets through what its group does not cover + $objects['ExtGState'][$mask['name']] = $this->writeObject(sprintf('<>>>', $mask['luminosity'] ? 'Luminosity' : 'Alpha', $objects['XObject'][$mask['form']], $mask['inverted'] ? ' /TR <>' : '')); } $this->writer->object($object); - $this->writer->write('<<' . $this->dictionary('XObject', $images) . $this->dictionary('ExtGState', $states) . '>>'); + $dictionaries = ''; + foreach ($objects as $type => $named) { + $dictionaries .= $this->dictionary($type, $named); + } + $this->writer->write('<<' . $dictionaries . '>>'); $this->writer->write('endobj'); } $this->resources = []; } + /** + * @param array $shading As GlyphResources::shading() takes it + * + * @return string The shading's dictionary: axial from two points, or radial from two circles, its + * colours stitched from one stop to the next, and extended past both ends + */ + private function shadingDictionary(array $shading) + { + $colour = function ($colour) { + return implode(' ', array_map(function ($value) { + return sprintf('%.3F', $value); + }, $colour)); + }; + + $stops = $shading['stops']; + $functions = []; + $bounds = []; + for ($i = 0; $i < count($stops) - 1; $i++) { + $functions[] = sprintf('<>', $colour($stops[$i][1]), $colour($stops[$i + 1][1])); + if ($i > 0) { + $bounds[] = sprintf('%.4F', $stops[$i][0]); + } + } + + $function = $functions[0]; + if (count($functions) > 1) { + $function = sprintf('<>', implode(' ', $functions), implode(' ', $bounds), trim(str_repeat('0 1 ', count($functions)))); + } + + return sprintf( + '<>', + count($shading['coords']) === 4 ? 2 : 3, + count($stops[0][1]) === 1 ? 'Gray' : 'RGB', + $colour($shading['coords']), + $function + ); + } + /** * @param string $type A kind of resource, e.g. 'XObject' * @param int[] $objects Name => the object number it names @@ -410,7 +567,7 @@ private function writeSubset(array $font, array $subset, array $sources, array $ $widths = []; $procedures = []; $differences = ''; - $this->drawn = ['images' => [], 'states' => []]; + $this->drawn = ['images' => [], 'states' => [], 'shadings' => [], 'forms' => [], 'masks' => []]; foreach ($subset as $code => $char) { $width = '0.000'; if ($char && isset($charToGlyph[$char])) { @@ -425,9 +582,10 @@ private function writeSubset(array $font, array $subset, array $sources, array $ $widths[] = $width; } - // A subset whose glyphs draw no image nor graphics state - one drawn blank, say - names no resource + // A subset whose glyphs draw with nothing but paths and colours - one drawn blank, say - names no + // resource $resources = '<<>>'; - if ($this->drawn['images'] || $this->drawn['states']) { + if (array_filter($this->drawn)) { $this->writer->object(false, true); $this->resources[$this->mpdf->n] = $this->drawn; $resources = $this->mpdf->n . ' 0 R'; @@ -455,31 +613,53 @@ private function writeSubset(array $font, array $subset, array $sources, array $ $this->writer->write('>>'); $this->writer->write('endobj'); - $this->writer->object(); $entries = ''; foreach (array_keys($procedures) as $i => $glyph) { $entries .= '/g' . $glyph . ' ' . ($procedureObject + $i) . ' 0 R '; } - $this->writer->write('<<' . $entries . '>>'); - $this->writer->write('endobj'); + $this->writeObject('<<' . $entries . '>>'); $this->writeToUnicode($codes, $subset, $ligatureText); foreach ($procedures as $content) { - $this->writer->object(); - if ($this->mpdf->compress) { - $content = gzcompress($content); - $this->writer->write('<>'); - } else { - $this->writer->write('<>'); - } - $this->writer->stream($content); - $this->writer->write('endobj'); + $this->writeStream('', $content); } return $fontObject; } + /** + * @param string $body The object, e.g. a dictionary + * + * @return int Its object number + */ + private function writeObject($body) + { + $this->writer->object(); + $this->writer->write($body); + $this->writer->write('endobj'); + + return $this->mpdf->n; + } + + /** + * Writes a stream object, deflated where the document is compressed + * + * @param string $dictionary What its dictionary holds besides its length and filter + * @param string $content + */ + private function writeStream($dictionary, $content) + { + $this->writer->object(); + if ($this->mpdf->compress) { + $content = gzcompress($content); + $dictionary .= ' /Filter /FlateDecode'; + } + $this->writer->write('<<' . ltrim($dictionary . ' /Length ' . strlen($content)) . '>>'); + $this->writer->stream($content); + $this->writer->write('endobj'); + } + /** * @param int $glyph The glyph id * @param ColorGlyphSource[] $sources What draws a glyph, the first that has it diff --git a/tests/Mpdf/ColorFontTest.php b/tests/Mpdf/ColorFontTest.php index 3696ea256..09fd641bb 100644 --- a/tests/Mpdf/ColorFontTest.php +++ b/tests/Mpdf/ColorFontTest.php @@ -29,6 +29,7 @@ private function mpdf(array $config = []) 'cbdt' => ['R' => 'TestEmoji-CBDT.ttf', 'useOTL' => 0xFF], 'sbix' => ['R' => 'TestEmoji-sbix.ttf', 'useOTL' => 0xFF], 'colr' => ['R' => 'TestEmoji-COLRv0.ttf', 'useOTL' => 0xFF], + 'colrv1' => ['R' => 'TestEmoji-COLRv1.ttf', 'useOTL' => 0xFF], 'notoemoji' => ['R' => 'NotoEmoji-Regular.ttf'], ], 'default_font' => 'cbdt', @@ -237,6 +238,35 @@ public function testAColrGlyphIsItsLayersAndAPlainGlyphItsOutline() $this->assertMatchesRegularExpression('/\/Type \/ExtGState\s*\/BM \/Normal\s*\/ca 0\.50/', $objects[(int) $state[1]]); } + /** + * TestEmoji-COLRv1's heart is a gradient kept to the heart by SRC_IN, and its keycap a square with + * the digit cut out by DEST_OUT: each is a group drawn through a soft mask, both named by the font's + * own resources beside the gradient's shading, and drawing with those same resources + */ + public function testAColrV1GlyphsShadingsGroupsAndMasksAreTheFontsResources() + { + $objects = $this->objects([0x2764, 0x31, 0x20E3], ['default_font' => 'colrv1']); + $font = $this->objectMatching($objects, '/\/Subtype \/Type3/'); + $resources = $this->referenced($objects, $font, 'Resources'); + + $this->assertSame(1, preg_match('/q \/(SM\d+) gs \/(Fx\d+) Do Q/', $this->procedure($objects, 13), $heart)); + $mask = $this->referenced($objects, $resources, $heart[1]); + $group = $this->referenced($objects, $resources, $heart[2]); + $shading = $this->referenced($objects, $resources, 'Sh1'); + + $this->assertMatchesRegularExpression('/^<<\/Type \/ExtGState \/SMask <<\/Type \/Mask \/S \/Alpha \/G \d+ 0 R>>>>$/', $mask); + $this->assertStringContainsString("500 -50 m\n80 400 l\n", $this->referenced($objects, $mask, 'G'), 'the mask is the heart'); + $this->assertStringContainsString("W n\n/Sh1 sh\n", $group, 'the group is the gradient'); + $this->assertMatchesRegularExpression('/^<<\/Type \/XObject \/Subtype \/Form \/BBox \[20\.000 -100\.000 980\.000 850\.000\] \/Group <<\/S \/Transparency>> \/Resources \d+ 0 R /', $group); + $this->assertStringStartsWith('<> /Extend [true true]>>', $shading); + + $this->assertSame(1, preg_match('/q \/(SM\d+) gs/', $this->procedure($objects, 24), $keycap)); + $this->assertStringContainsString('/TR <>', $this->referenced($objects, $resources, $keycap[1]), 'the digit is cut out'); + + // The groups draw with the font's resources + $this->assertSame($resources, $this->referenced($objects, $group, 'Resources')); + } + /** * 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 diff --git a/tests/Mpdf/Fonts/Color/ColorLineTest.php b/tests/Mpdf/Fonts/Color/ColorLineTest.php new file mode 100644 index 000000000..b48aa1bbe --- /dev/null +++ b/tests/Mpdf/Fonts/Color/ColorLineTest.php @@ -0,0 +1,226 @@ +assertSame([[0.0, self::GREEN, 1.0], [0.5, self::RED, 1.0], [0.5, self::BLUE, 1.0], [1.0, self::BLUE, 1.0]], $line->stops); + } + + /** + * Stops lying over any span are taken onto 0 to 1 + */ + public function testStopsAreTakenOntoZeroToOne() + { + $line = new ColorLine(ColorLine::PAD, [[-0.5, self::RED, 1.0], [0.0, self::GREEN, 1.0], [1.5, self::BLUE, 1.0]]); + + $this->assertSame([-0.5, 1.5], $line->span()); + $this->assertSame([[0.0, self::RED, 1.0], [0.25, self::GREEN, 1.0], [1.0, self::BLUE, 1.0]], $line->normalised()); + } + + /** + * REPEAT draws the stops again over each span, and REFLECT every other span back to front + * + * @dataProvider spreads + * + * @param int $extend REPEAT or REFLECT + * @param float[] $offsets Each stop's offset across the three spans + * @param array[] $colours Each stop's colour + */ + public function testStopsAreDrawnAgainOverEachSpan($extend, array $offsets, array $colours) + { + $line = new ColorLine($extend, [[0.0, self::RED, 1.0], [0.25, self::GREEN, 1.0], [1.0, self::BLUE, 1.0]]); + + $spread = $line->normalised(-1, 2); + + $this->assertEqualsWithDelta($offsets, array_column($spread, 0), 1e-9); + $this->assertSame($colours, array_column($spread, 1)); + } + + /** + * @return array[] Each extend mode, and the stops it draws over spans -1 to 1 + */ + public function spreads() + { + return [ + 'REPEAT' => [ColorLine::REPEAT, [0, 0.25 / 3, 1 / 3, 1 / 3, 1.25 / 3, 2 / 3, 2 / 3, 2.25 / 3, 1], [self::RED, self::GREEN, self::BLUE, self::RED, self::GREEN, self::BLUE, self::RED, self::GREEN, self::BLUE]], + 'REFLECT' => [ColorLine::REFLECT, [0, 0.75 / 3, 1 / 3, 1 / 3, 1.25 / 3, 2 / 3, 2 / 3, 2.75 / 3, 1], [self::BLUE, self::GREEN, self::RED, self::RED, self::GREEN, self::BLUE, self::BLUE, self::GREEN, self::RED]], + ]; + } + + /** + * PAD's stops are its own however many spans are asked for, and an extend mode the spec does not + * define is PAD + */ + public function testPadAndAnUnknownModeDrawTheStopsOnce() + { + $stops = [[0.0, self::RED, 1.0], [1.0, self::BLUE, 1.0]]; + + $this->assertSame($stops, (new ColorLine(ColorLine::PAD, $stops))->normalised(-1, 2)); + $this->assertSame(ColorLine::PAD, (new ColorLine(7, $stops))->extend); + } + + /** + * A line whose stops share one alpha is drawn at that alpha, and one whose alphas differ needs a mask + */ + public function testTheOpacityIsTheStopsAlphaWhereTheyShareOne() + { + $this->assertSame(0.5, (new ColorLine(ColorLine::PAD, [[0.0, self::RED, 0.5], [1.0, self::BLUE, 0.5]]))->opacity()); + $this->assertNull((new ColorLine(ColorLine::PAD, [[0.0, self::RED, 0.5], [1.0, self::BLUE, 1.0]]))->opacity()); + } + + /** + * Stops all at one offset are a hard edge there, the first stop's colour below and the last's from + * it on, padded either way, since there is no length to repeat + */ + public function testStopsSharingOneOffsetAreAHardEdge() + { + $line = new ColorLine(ColorLine::REPEAT, [[0.5, self::RED, 1.0], [0.5, self::GREEN, 1.0], [0.5, self::BLUE, 1.0]]); + + $this->assertSame(ColorLine::PAD, $line->extend); + $this->assertSame([-0.5, 1.5], $line->span()); + $this->assertSame([[0.0, self::RED, 1.0], [0.5, self::RED, 1.0], [0.5, self::BLUE, 1.0], [1.0, self::BLUE, 1.0]], $line->normalised()); + } + + /** + * Between two stops whose alphas and colours differ, stops are added in the colours of premultiplied + * interpolation, enough that a colour interpolated straight between them, times its alpha, strays no + * further than TOLERANCE from premultiplied interpolation; more where the alpha changes more, up to + * MAX_PARTS + * + * @dataProvider varyingAlphas + * + * @param float $from The first stop's alpha + * @param float $to The second's + */ + public function testStopsAreAddedWhereTheAlphaAndColourBothVary($from, $to) + { + $line = [[0.0, self::RED, $from], [1.0, self::BLUE, $to]]; + $stops = ColorLine::premultiplied($line); + + $this->assertGreaterThan(2, count($stops)); + foreach ($stops as $stop) { + $this->assertEqualsWithDelta(self::premultipliedAt($line, $stop[0]), $stop[1], 1e-9); + } + + for ($i = 1; $i < count($stops); $i++) { + for ($t = 0.1; $t < 1; $t += 0.1) { + $offset = $stops[$i - 1][0] + ($stops[$i][0] - $stops[$i - 1][0]) * $t; + $alpha = $from + ($to - $from) * $offset; + $exact = self::premultipliedAt($line, $offset); + foreach ([0, 2] as $channel) { + $straight = $stops[$i - 1][1][$channel] + ($stops[$i][1][$channel] - $stops[$i - 1][1][$channel]) * $t; + $this->assertLessThanOrEqual(ColorLine::TOLERANCE, $alpha * abs($straight - $exact[$channel])); + } + } + } + + $this->assertLessThan(count($stops), count(ColorLine::premultiplied([[0.0, self::RED, 0.8], [1.0, self::BLUE, 1.0]]))); + } + + /** + * However steeply the alpha changes, a span is divided into no more than MAX_PARTS + */ + public function testASpanIsDividedIntoNoMoreThanMaxParts() + { + $this->assertCount(ColorLine::MAX_PARTS + 1, ColorLine::premultiplied([[0.0, self::RED, 1.0], [1.0, self::BLUE, 0.001]])); + } + + /** + * @return float[][] Alphas at the two ends of a line from red to blue + */ + public function varyingAlphas() + { + return [ + 'rising from a fifth' => [0.2, 1.0], + 'falling to a tenth' => [1.0, 0.1], + ]; + } + + /** + * A stop at alpha 0 has no colour of its own: each side of it is its neighbour's colour, so a stop + * between two is drawn twice + */ + public function testAStopAtAlphaZeroTakesItsNeighboursColour() + { + $fading = ColorLine::premultiplied([[0.0, self::RED, 0.0], [1.0, self::BLUE, 1.0]]); + $this->assertSame([self::BLUE], array_values(array_unique(array_column($fading, 1), SORT_REGULAR))); + + $gap = ColorLine::premultiplied([[0.0, self::RED, 1.0], [0.5, self::GREEN, 0.0], [1.0, self::BLUE, 1.0]]); + $middle = array_values(array_filter($gap, function ($stop) { + return $stop[0] == 0.5; + })); + $this->assertEqualsWithDelta([[0.5, self::RED, 0], [0.5, self::BLUE, 0]], $middle, 1e-9); + } + + /** + * Where either the alpha or the colour stays the same, interpolating each alone is already right + */ + public function testStopsAreLeftWhereOnlyOneOfAlphaAndColourVaries() + { + $constant = [[0.0, self::RED, 0.5], [1.0, self::BLUE, 0.5]]; + $fade = [[0.0, self::RED, 0.0], [0.5, self::RED, 0.0], [1.0, self::RED, 1.0]]; + + $this->assertSame($constant, ColorLine::premultiplied($constant)); + $this->assertSame($fade, ColorLine::premultiplied($fade)); + } + + /** + * @param array[] $line Two stops, [offset, colour, alpha], at 0 and 1 + * @param float $offset + * + * @return float[] The colour premultiplied interpolation gives at the offset + */ + private static function premultipliedAt(array $line, $offset) + { + list(, $a, $alphaA) = $line[0]; + list(, $b, $alphaB) = $line[1]; + $alpha = $alphaA + ($alphaB - $alphaA) * $offset; + + $colour = []; + foreach ($a as $channel => $value) { + $colour[] = ($value * $alphaA * (1 - $offset) + $b[$channel] * $alphaB * $offset) / $alpha; + } + + return $colour; + } + + /** + * Stops cut to part of their range: one interpolated at each end, and the part taken onto 0 to 1 + */ + public function testStopsAreCutToAPartOfTheirRange() + { + $stops = [[0.0, self::RED, 1.0], [0.25, self::GREEN, 1.0], [1.0, self::BLUE, 1.0]]; + + $this->assertEqualsWithDelta( + [[0, [0.5, 0.5, 0], 1], [0.2, self::GREEN, 1], [1, [0, 1 / 3, 2 / 3], 1]], + ColorLine::between($stops, 0.125, 0.75), + 1e-9 + ); + } +} diff --git a/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php b/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php index 41d0250b5..5e2715070 100644 --- a/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php +++ b/tests/Mpdf/Fonts/Color/ColrV0SourceTest.php @@ -159,16 +159,6 @@ private function colr($index = 0) 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 diff --git a/tests/Mpdf/Fonts/Color/ColrV1SourceTest.php b/tests/Mpdf/Fonts/Color/ColrV1SourceTest.php new file mode 100644 index 000000000..09c8e5776 --- /dev/null +++ b/tests/Mpdf/Fonts/Color/ColrV1SourceTest.php @@ -0,0 +1,482 @@ +logger = new TestLogger(); + $this->resources = new RecordingResources(); + + $ttf = new TTFontFile(new FontCache(new Cache(__DIR__ . '/../../tmp/mpdf/ttfontdata')), 'win'); + $this->reader = $ttf->openFont(__DIR__ . '/../../../data/ttf/color/TestEmoji-COLRv1.ttf'); + $this->source = new ColrV1Source(new ColorFontFile($ttf, $this->reader, 1000, $this->logger)); + } + + /** + * 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(); + } + + /** + * The face is its outline clipping a radial gradient from white to yellow, then its eyes filled + * black and its mouth in the colour of the text, all inside the font's bounding box + */ + public function testARadialGradientIsARadialShadingInsideTheOutline() + { + $content = $this->source->draw(12, $this->resources); + + $this->assertStringStartsWith(self::CLIP . "q\n950 350 m\n", $content); + $this->assertStringContainsString("h\nW n\n/Sh1 sh\nQ\nq 0.000 0.000 0.000 rg\n330 400 m\n", $content, 'the eyes, black'); + $this->assertStringContainsString("f\nQ\n300 250 m\n", $content, 'the mouth, which sets no colour'); + $this->assertStringEndsWith("f\nQ\n", $content); + + $this->assertSame([['coords' => [400, 450, 0, 500, 350, 450], 'stops' => [[0, [1, 1, 1]], [1, [1, 0.8, 0.2]]]]], $this->resources->shadings); + } + + /** + * The heart is a gradient over a square, kept to the heart's outline by SRC_IN: the square drawn + * as a group, through a soft mask of the heart's alpha + */ + public function testSrcInDrawsTheSourceThroughTheBackdropsAlpha() + { + $content = $this->source->draw(13, $this->resources); + + $this->assertStringStartsWith(self::CLIP . "q /SM1 gs /Fx1 Do Q\n", $content); + + list($mask, $box, $luminosity, $inverted) = $this->resources->masks[0]; + $this->assertStringStartsWith("q 0.878 0.141 0.369 rg\n500 -50 m\n80 400 l\n", $mask, 'the heart'); + $this->assertSame([20, -100, 980, 850], $box); + $this->assertFalse($luminosity); + $this->assertFalse($inverted); + + $this->assertStringStartsWith("q\n50 -100 m\n50 800 l\n", $this->resources->groups[0][0], 'the square'); + $this->assertStringEndsWith("W n\n/Sh1 sh\nQ\n", $this->resources->groups[0][0]); + $this->assertSame([500, 750, 500, -50], $this->resources->shadings[0]['coords']); + } + + /** + * The keycap is a grey square with the digit cut out of it by DEST_OUT: the square through a mask of + * where the digit is not + */ + public function testDestOutDrawsTheBackdropWhereTheSourceIsNot() + { + $this->assertSame(self::CLIP . "q /SM1 gs /Fx1 Do Q\nQ\n", $this->source->draw(24, $this->resources)); + + $this->assertStringStartsWith("q 0.000 0.000 0.000 rg\n250 0 m\n", $this->resources->masks[0][0], 'the digit'); + $this->assertTrue($this->resources->masks[0][3], 'inverted'); + $this->assertStringStartsWith("q 0.600 0.600 0.600 rg\n50 -100 m\n", $this->resources->groups[0][0], 'the square'); + } + + /** + * The family's faces are multiplied onto a yellow square: the square, then the faces as a group + * blended with it, the two drawn as an isolated group so the faces blend with nothing under the + * glyph + */ + public function testABlendModeBlendsTheSourceWithTheBackdropAlone() + { + $this->assertSame(self::CLIP . "/Fx2 Do\nQ\n", $this->source->draw(22, $this->resources)); + + list($faces, $composite) = $this->resources->groups; + $this->assertStringStartsWith("q 0.200 0.400 0.800 rg\n", $faces[0]); + $this->assertFalse($faces[2]); + $this->assertStringStartsWith("q 1.000 0.800 0.200 rg\n50 -100 m\n", $composite[0]); + $this->assertStringEndsWith("f\nQ\nq /Multiply gs /Fx1 Do Q\n", $composite[0]); + $this->assertTrue($composite[2], 'isolated'); + } + + /** + * The woman's face is a linear gradient from 400 to 600 reflected across the bounding box, from + * opaque red to red at a quarter: two spans each way cover it, so the shading runs from 0 to 1000, + * and its alpha is a soft mask of the brightness of a grey shading of the same shape + */ + public function testAReflectedGradientWhoseAlphaVariesIsMaskedByAGreyShading() + { + $content = $this->source->draw(15, $this->resources); + + $this->assertStringContainsString("W n\nq /SM1 gs\n/Sh1 sh\nQ\nQ\n", $content); + + list($colour, $alpha) = $this->resources->shadings; + $this->assertSame([0, 0, 1000, 0], $colour['coords']); + $this->assertSame($colour['coords'], $alpha['coords']); + + $this->assertSame( + [[0, [1]], [0.2, [0.25]], [0.2, [0.25]], [0.4, [1]], [0.4, [1]], [0.6, [0.25]], [0.6, [0.25]], [0.8, [1]], [0.8, [1]], [1, [0.25]]], + $alpha['stops'], + 'the stops, which the font lists last first, back to front every other span' + ); + $this->assertSame(["/Sh2 sh\n", [20, -100, 980, 850], true, false], $this->resources->masks[0]); + } + + /** + * The girl's face is green and white rings of 120 units: a radial gradient repeated out to the + * bounding box's furthest corner, six rings out + */ + public function testARepeatedRadialGradientIsDrawnOutToTheCornersOfTheClip() + { + $this->source->draw(16, $this->resources); + + $shading = $this->resources->shadings[0]; + $this->assertSame([500, 350, 0, 500, 350, 720], $shading['coords']); + $this->assertCount(12, $shading['stops']); + $this->assertSame([1 / 6, [1, 1, 1]], $shading['stops'][1]); + $this->assertSame([1 / 6, [0.2 * 2 / 3, 0.2 * 10 / 3, 0.2 * 4 / 3]], $shading['stops'][2]); + } + + /** + * Each transform is a cm, the paints under it drawn in its space + * + * @dataProvider transforms + * + * @param int $glyph + * @param string $matrices Each cm, from the outermost in + */ + public function testATransformIsACm($glyph, $matrices) + { + $this->assertStringStartsWith($matrices, substr($this->source->draw($glyph, $this->resources), strlen(self::CLIP))); + } + + /** + * @return array[] Each glyph drawn under transforms, and its cm operators + */ + public function transforms() + { + return [ + 'an affine transform' => [17, "q 0.89999 0.10001 -0.10001 0.89999 80 0 cm\n"], + 'a uniform scale about the centre' => [14, "q 0.79999 0 0 0.79999 100.0061 70.00427 cm\n"], + 'a rotation, then a skew' => [19, "q 0.98482 -0.17361 0.17361 0.98482 0 0 cm\nq 1 0 -0.26788 1 0 0 cm\n"], + 'a move, then a scale' => [25, "q 1 0 0 1 100 0 cm\nq 0.79999 0 0 1 0 0 cm\n"], + ]; + } + + /** + * The thumb is turned about its centre and clipped to its own clip box, not the font's + */ + public function testAGlyphIsClippedToItsClipBox() + { + $this->assertStringStartsWith("q 50 -100 900 700 re W n\nq 0.93972 0.34194 -0.34194 0.93972 158.36664 -148.36569 cm\n", $this->source->draw(21, $this->resources)); + } + + /** + * The regional indicator U draws A, turned, then a stripe over it + */ + public function testPaintColrGlyphDrawsTheOtherGlyph() + { + $a = $this->source->draw(17, $this->resources); + $u = $this->source->draw(18, $this->resources); + + $this->assertStringStartsWith(substr($a, 0, -2), $u); + $this->assertStringEndsWith("q 0.878 0.141 0.369 rg\n50 250 m\n50 450 l\n950 450 l\n950 250 l\nh\nf\nQ\nQ\n", $u); + } + + /** + * The skin tone is a sweep gradient, which is filled in its middle stop's colour, and logged + */ + public function testASweepGradientIsItsMiddleStopAndIsLogged() + { + $this->assertSame(self::CLIP . "q\n50 -100 m\n50 800 l\n950 800 l\n950 -100 l\nh\nW n\nq 0.878 0.141 0.369 rg\n20 -100 960 950 re f\nQ\nQ\nQ\n", $this->source->draw(20, $this->resources)); + $this->assertTrue($this->logger->hasWarningThatContains('Colour glyph 20 has a sweep gradient')); + } + + /** + * The flag of England's white square is a PaintVarSolid, read as the PaintSolid it varies + */ + public function testAVarPaintIsReadAsThePaintItVaries() + { + $this->assertStringStartsWith(self::CLIP . "q 1.000 1.000 1.000 rg\n50 -100 m\n", $this->source->draw(26, $this->resources)); + } + + /** + * A glyph with no version 1 paint is left to the next source: ColrV0Source, where the font has + * version 0 layers, then its outline + */ + public function testAGlyphWithNoPaintIsNotDrawn() + { + $this->assertNull($this->source->draw(2, $this->resources), 'the number sign'); + } + + /** + * A solid colour under a glyph fills the glyph's outline, as a version 0 layer does + */ + public function testASyntheticSolidGlyphFillsItsOutline() + { + $this->assertSame("q 0 0 1000 1000 re W n\nq 1.000 0.000 0.000 rg\n0 0 m\n500 700 l\n1000 0 l\nh\nf\nQ\nQ\n", $this->synthetic($this->solidTriangle())->draw(1, $this->resources)); + } + + /** + * A PaintVarLinearGradient's colour line is a VarColorLine, whose stops are four bytes longer + */ + public function testAVarGradientReadsItsVarColorLine() + { + // PaintGlyph, PaintVarLinearGradient, then its VarColorLine: two stops, each with a varIndexBase + $paints = $this->paintGlyph() + . pack('C', 5) . self::u24(20) . pack('n6', 0, 0, 1000, 0, 0, 1000) . pack('N', 0) + . pack('Cn', 0, 2) . pack('n3N', 0, 0, 0x4000, 0) . pack('n3N', 0x4000, 0xFFFF, 0x4000, 0); + + $this->synthetic($paints)->draw(1, $this->resources); + + $this->assertSame([['coords' => [0, 0, 1000, 0], 'stops' => [[0, [1, 0, 0]], [1, [0, 0, 0]]]]], $this->resources->shadings); + } + + /** + * A colour line of one stop is that colour throughout + */ + public function testAGradientOfOneStopIsASolidFill() + { + $paints = $this->paintGlyph() . $this->linear([0, 0, 1000, 0, 0, 1000]) . pack('Cn', 0, 1) . pack('n3', 0, 0, 0x4000); + + $this->assertStringContainsString("W n\nq 1.000 0.000 0.000 rg\n0 0 1000 1000 re f\nQ\n", $this->synthetic($paints)->draw(1, $this->resources)); + $this->assertSame([], $this->resources->shadings); + } + + /** + * Circles from radius 10 to 50 over stops from -0.5 to 1 would start at a radius of -10: the + * shading starts where the radius is 0 instead, a sixth of the way along, in the colour there + */ + public function testAPaddedRadialGradientIsCutWhereItsRadiusIsZero() + { + $stops = pack('Cn', 0, 2) . pack('n3', -0x2000, 0, 0x4000) . pack('n3', 0x4000, 0xFFFF, 0x4000); + $paints = $this->paintGlyph() . $this->radial([0, 0, 10, 100, 0, 50]) . $stops; + + $this->synthetic($paints)->draw(1, $this->resources); + + $shading = $this->resources->shadings[0]; + $this->assertEqualsWithDelta([-25, 0, 0, 100, 0, 50], $shading['coords'], 1e-9); + $this->assertEqualsWithDelta([[0, [5 / 6, 0, 0]], [1, [0, 0, 0]]], $shading['stops'], 1e-9); + } + + /** + * A repeated gradient whose radius grows 40 a span from 10 reaches 0 three quarters into the span + * before the stops': the shading starts there, a quarter of the way from red to black + */ + public function testARepeatedRadialGradientIsCutWhereItsRadiusIsZero() + { + $stops = pack('Cn', 1, 2) . pack('n3', 0, 0, 0x4000) . pack('n3', 0x4000, 0xFFFF, 0x4000); + $paints = $this->paintGlyph() . $this->radial([500, 500, 10, 500, 500, 50]) . $stops; + + $this->synthetic($paints)->draw(1, $this->resources); + + $shading = $this->resources->shadings[0]; + $this->assertEqualsWithDelta([500, 500, 0], array_slice($shading['coords'], 0, 3), 1e-9); + $this->assertEqualsWithDelta([0, [0.25, 0, 0]], $shading['stops'][0], 1e-9); + $this->assertEqualsWithDelta([0.25 / 18.25, [0, 0, 0]], $shading['stops'][1], 1e-9); + } + + /** + * Two stops at one offset are a hard edge across the gradient there + */ + public function testStopsAtOneOffsetAreAHardEdge() + { + $stops = pack('Cn', 0, 2) . pack('n3', 0x2000, 0, 0x4000) . pack('n3', 0x2000, 0xFFFF, 0x4000); + $paints = $this->paintGlyph() . $this->linear([0, 0, 1000, 0, 0, 1000]) . $stops; + + $this->synthetic($paints)->draw(1, $this->resources); + + $this->assertEqualsWithDelta( + [['coords' => [-500, 0, 1500, 0], 'stops' => [[0, [1, 0, 0]], [0.5, [1, 0, 0]], [0.5, [0, 0, 0]], [1, [0, 0, 0]]]]], + $this->resources->shadings, + 1e-9 + ); + } + + /** + * A composite mode the spec does not define draws nothing, as CLEAR does, and PLUS, which PDF cannot + * draw, is the source over the backdrop; each is logged + */ + public function testAnUnknownCompositeModeIsClearAndPlusIsSourceOver() + { + // PaintComposite, its source then its backdrop each the red triangle + $composite = function ($mode) { + return pack('C', 32) . self::u24(8) . pack('C', $mode) . self::u24(19) . $this->solidTriangle() . $this->solidTriangle(); + }; + + $this->assertNull($this->synthetic($composite(28))->draw(1, $this->resources)); + $this->assertTrue($this->logger->hasWarningThatContains('Colour glyph 1 has composite mode 28, which is not in the spec and draws nothing')); + + $triangle = "q 1.000 0.000 0.000 rg\n0 0 m\n500 700 l\n1000 0 l\nh\nf\nQ\n"; + $this->assertSame("q 0 0 1000 1000 re W n\n" . $triangle . $triangle . "Q\n", $this->synthetic($composite(12))->draw(1, $this->resources)); + $this->assertTrue($this->logger->hasWarningThatContains('Colour glyph 1 has composite mode PLUS')); + } + + /** + * A paint graph that leads back to where it started stops there, and says so + */ + public function testAGraphThatLoopsIsDrawnOnlySoFar() + { + // PaintColrGlyph of glyph 1, itself + $this->assertNull($this->synthetic(pack('Cn', 11, 1))->draw(1, $this->resources)); + $this->assertTrue($this->logger->hasWarningThatContains('Colour glyph 1 has a paint graph that loops or nests too deep')); + } + + /** + * A paint of a format the spec does not define draws nothing, and is logged + */ + public function testAPaintOfAnUnknownFormatIsLogged() + { + $this->assertNull($this->synthetic(pack('C', 33))->draw(1, $this->resources)); + $this->assertTrue($this->logger->hasWarningThatContains('Colour glyph 1 has a paint of format 33')); + } + + /** + * A graph that runs past COLR, names layers the LayerList does not have, or is ill-formed as the + * spec says draws nothing + * + * @dataProvider graphsDrawingNothing + * + * @param string $paints Glyph 1's paints + * @param int[] $layers The LayerList, as where each layer's paint is among them + */ + public function testAGraphThatCannotBeDrawnDrawsNothing($paints, array $layers = []) + { + $this->assertNull($this->synthetic($paints, $layers)->draw(1, $this->resources)); + } + + /** + * @return array[] Glyph 1's paints, each drawing nothing + */ + public function graphsDrawingNothing() + { + $stops = pack('Cn', 0, 2) . pack('n3', 0, 0, 0x4000) . pack('n3', 0x4000, 0, 0x4000); + + return [ + 'a paint cut short' => [pack('C', 10) . "\0"], + 'a child past the end of COLR' => [$this->paintGlyph(2, 0x7FFF00)], + 'layers past the LayerList' => [pack('CCN', 1, 2, 0) . $this->solidTriangle(), [6]], + 'a transform past the end of COLR' => [pack('C', 12) . self::u24(7) . self::u24(0x7FFF00) . $this->solidTriangle()], + 'a scale to nothing' => [pack('C', 16) . self::u24(8) . pack('n2', 0, 0) . pack('Cnn', 2, 0, 0x4000)], + 'a transparent colour' => [$this->paintGlyph() . pack('Cnn', 2, 0, 0)], + 'a colour line of no stops' => [$this->paintGlyph() . $this->linear([0, 0, 1000, 0, 0, 1000]) . pack('Cn', 0, 0)], + 'a linear gradient whose second point is its first' => [$this->paintGlyph() . $this->linear([0, 0, 0, 0, 0, 1000]) . $stops], + 'a linear gradient whose points are in a line' => [$this->paintGlyph() . $this->linear([0, 0, 1000, 0, 500, 0]) . $stops], + 'a radial gradient of one circle twice' => [$this->paintGlyph() . $this->radial([500, 500, 100, 500, 500, 100]) . $stops], + 'a radial gradient whose stops all lie where its radius is below 0' => [ + $this->paintGlyph() . $this->radial([0, 0, 10, 0, 0, 50]) . pack('Cn', 0, 2) . pack('n3', -2 * 0x4000, 0, 0x4000) . pack('n3', -1 * 0x4000, 0, 0x4000), + ], + ]; + } + + /** + * @param int[] $circles x0, y0, r0, x1, y1, r1 + * + * @return string A PaintRadialGradient, its ColorLine to follow it + */ + private function radial(array $circles) + { + return pack('C', 6) . self::u24(16) . pack('n6', ...$circles); + } + + /** + * @param int $glyph The glyph whose outline clips, the triangle unless another is given + * @param int $child Where the child paint is, from this one: straight after it unless otherwise + * + * @return string A PaintGlyph + */ + private function paintGlyph($glyph = 2, $child = 6) + { + return pack('C', 10) . self::u24($child) . pack('n', $glyph); + } + + /** + * @return string A PaintGlyph of the triangle, filled with the palette's red + */ + private function solidTriangle() + { + return $this->paintGlyph() . pack('Cnn', 2, 0, 0x4000); + } + + /** + * @param int[] $points x0, y0, x1, y1, x2, y2 + * + * @return string A PaintLinearGradient, its ColorLine to follow it + */ + private function linear(array $points) + { + return pack('C', 4) . self::u24(16) . pack('n6', ...$points); + } + + /** + * @param int $value + * + * @return string An Offset24 + */ + private static function u24($value) + { + return substr(pack('N', $value), 1); + } + + /** + * @param string $paints Glyph 1's paints, one after another from its root, each child offset + * counted from the paint that names it + * @param int[] $layers The LayerList, as where each layer's paint is among $paints + * + * @return ColrV1Source Over a font of glyphs 0 to 2, glyph 2 the triangle, and a COLR of those + * paints beside a CPAL of one colour, opaque red + */ + private function synthetic($paints, array $layers = []) + { + // The header, the BaseGlyphList of glyph 1, the LayerList, then the paints + $start = 48 + 4 * count($layers); + $colr = pack('n2N2nN5', 1, 0, 0, 0, 0, 34, 44, 0, 0, 0) + . pack('NnN', 1, 1, $start - 34) + . pack('N', count($layers)); + foreach ($layers as $at) { + $colr .= pack('N', $start + $at - 44); + } + + $tables = $this->glyphTables(['', '', $this->triangle()]) + ['COLR' => $colr . $paints, 'CPAL' => $this->cpal()]; + list($ttf, $reader) = $this->openFont($this->sfnt($tables)); + + return new ColrV1Source(new ColorFontFile($ttf, $reader, 1000, $this->logger)); + } +} diff --git a/tests/Mpdf/Fonts/Color/RecordingResources.php b/tests/Mpdf/Fonts/Color/RecordingResources.php index 4af651b79..a00760b8c 100644 --- a/tests/Mpdf/Fonts/Color/RecordingResources.php +++ b/tests/Mpdf/Fonts/Color/RecordingResources.php @@ -3,7 +3,7 @@ namespace Mpdf\Fonts\Color; /** - * Hands out a name per image, and keeps the images + * Hands out a name per image, shading, group and soft mask, and keeps what each was registered with */ class RecordingResources implements GlyphResources { @@ -13,6 +13,21 @@ class RecordingResources implements GlyphResources */ public $images = []; + /** + * @var array[] Each shading registered, in order + */ + public $shadings = []; + + /** + * @var array[] Each group registered, in order, as [content, box, isolated] + */ + public $groups = []; + + /** + * @var array[] Each soft mask registered, in order, as [content, box, luminosity, inverted] + */ + public $masks = []; + /** * @var bool Whether every image is refused, as one that cannot be decoded is */ @@ -39,4 +54,42 @@ public function alpha($opacity) { return sprintf('/GS%.2F gs', $opacity); } + + /** + * @inheritdoc + */ + public function blend($mode) + { + return '/' . $mode . ' gs'; + } + + /** + * @inheritdoc + */ + public function shading(array $shading) + { + $this->shadings[] = $shading; + + return '/Sh' . count($this->shadings); + } + + /** + * @inheritdoc + */ + public function group($content, array $box, $isolated = false) + { + $this->groups[] = [$content, $box, $isolated]; + + return '/Fx' . count($this->groups); + } + + /** + * @inheritdoc + */ + public function softMask($content, array $box, $luminosity = false, $inverted = false) + { + $this->masks[] = [$content, $box, $luminosity, $inverted]; + + return '/SM' . count($this->masks) . ' gs'; + } } diff --git a/tests/Mpdf/Fonts/Color/SyntheticFonts.php b/tests/Mpdf/Fonts/Color/SyntheticFonts.php index bb9985d34..53e7f2621 100644 --- a/tests/Mpdf/Fonts/Color/SyntheticFonts.php +++ b/tests/Mpdf/Fonts/Color/SyntheticFonts.php @@ -47,7 +47,8 @@ private function sfnt(array $tables, $cut = 0) /** * @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 + * @return string[] head, maxp, loca and glyf for those glyphs, loca in its long format, the font's + * bounding box a square of 1000 units */ private function glyphTables(array $glyphs) { @@ -58,8 +59,8 @@ private function glyphTables(array $glyphs) $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); + // unitsPerEm at 18, the bounding box from 0,0 to 1000,1000 at 36, indexToLocFormat at 50 + $head = str_repeat("\0", 18) . pack('n', 1000) . str_repeat("\0", 16) . pack('n4', 0, 0, 1000, 1000) . str_repeat("\0", 6) . pack('n2', 1, 0); return ['head' => $head, 'maxp' => pack('Nn', 0x00005000, count($glyphs)), 'loca' => $loca, 'glyf' => $glyf]; } @@ -73,6 +74,16 @@ 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); } + /** + * @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"; + } + /** * Writes a font and opens it * diff --git a/tests/Mpdf/Fonts/ColorFormatsTest.php b/tests/Mpdf/Fonts/ColorFormatsTest.php index 7111ccae8..c02847cce 100644 --- a/tests/Mpdf/Fonts/ColorFormatsTest.php +++ b/tests/Mpdf/Fonts/ColorFormatsTest.php @@ -102,15 +102,20 @@ private function read($name) } /** - * A font is drawn in the first format mPDF draws that it carries, and in none where the document may - * not use colour + * A font is drawn in colour where it carries a format mPDF draws, and not where the document may not + * use colour */ - public function testAFontIsDrawnInTheFirstFormatItCarriesThatMpdfDraws() + public function testAFontIsDrawnInColourWhereItCarriesAFormatMpdfDraws() { - $this->assertSame('CBDT', ColorFormats::choose(['COLRv1', 'CBDT', 'sbix'], true)); - $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'); + $mpdf = new Mpdf(['mode' => 'utf-8']); + + $this->assertTrue(ColorFormats::drawsInColor(['colorFormats' => ['SVG', 'CBDT', 'sbix']], $mpdf)); + $this->assertTrue(ColorFormats::drawsInColor(['colorFormats' => ['COLRv1', 'COLRv0']], $mpdf)); + $this->assertFalse(ColorFormats::drawsInColor(['colorFormats' => ['SVG']], $mpdf), 'a format mPDF does not draw yet is not drawn'); + $this->assertFalse(ColorFormats::drawsInColor(['colorFormats' => []], $mpdf), 'nor is a font with no colour'); + + $mpdf->PDFA = true; + $this->assertFalse(ColorFormats::drawsInColor(['colorFormats' => ['CBDT']], $mpdf), 'nor anything, where colour is off'); } /** @@ -142,15 +147,16 @@ public function testAFontHasOutlinesOnlyWhereAGlyphHasOne() } /** - * A glyph is drawn by the font's colour format, then by its outline, and by its outline alone - * where colour is off + * A glyph is drawn by the first of the font's colour formats that has it, 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\ColrV1Source', 'Mpdf\Fonts\Color\ColrV0Source', 'Mpdf\Fonts\Color\OutlineSource'], ColorFormats::sources($colr, $mpdf), 'a glyph with no version 1 paint drawn from its version 0 layers'); + $this->assertSame(['Mpdf\Fonts\Color\CbdtSource', 'Mpdf\Fonts\Color\SbixSource'], ColorFormats::sources(['colorFormats' => ['sbix', 'SVG', 'CBDT'], 'hasOutlines' => false], $mpdf), 'in the order of SOURCES, less what mPDF does not draw'); $this->assertSame(['Mpdf\Fonts\Color\CbdtSource'], ColorFormats::sources(['colorFormats' => ['CBDT'], 'hasOutlines' => false], $mpdf)); $mpdf->PDFA = true; diff --git a/tests/Snapshots/ColorEmojiColrV1SnapshotTest.php b/tests/Snapshots/ColorEmojiColrV1SnapshotTest.php new file mode 100644 index 000000000..a31af2bae --- /dev/null +++ b/tests/Snapshots/ColorEmojiColrV1SnapshotTest.php @@ -0,0 +1,52 @@ +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' => ['colrv1' => ['R' => 'TestEmoji-COLRv1.ttf', 'useOTL' => 0xFF]], + 'default_font' => 'dejavusans', + 'useSubstitutions' => true, + 'backupSubsFont' => ['colrv1'], + ]); + + $emoji = '😀 ❤️ 👨 👩 👧 🇦 🇺 🏴 🏽 👍 ' + . '👨‍👩‍👧 🇦🇺 1️⃣ 👍🏽 ' + . '🏴󠁧󠁢󠁥󠁮󠁧󠁿'; + + $this->mpdf->WriteHTML( + '

Emoji ' . $emoji . ' end

' + . '

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

' + . '

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

' + ); + } +} diff --git a/tests/data/snapshots/color-emoji-colrv1.pdf b/tests/data/snapshots/color-emoji-colrv1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a790da7b0bcb7dc8b15128dc3456360f816c14e9 GIT binary patch literal 56209 zcmd3O2Ow5``@fxR5{bBtLS@{`ecQ?2WoJfq_Q)pL*%Gp66e8I(BqZ5nW@cq(WcELI zPw)Gnp7;6J?|tf3_c-T1=Q^MBIoJ34Uf=8cy{@xYM8w#@>@d8u;}dONcu){0$WqS~ zkB^T-$;rw9#BoK}$N&#`w=uA=1A&2G74SG@4fIWPFIzf-G&q4BaBes|1jz|P!ociE zZcZ&c4ka5C^FMxO=i~$q`SSklVG0Jemi9I`3~WJ=KjezrSlU~G`1n9yul>h1ha#|T zqibPng#vNI3B(}`>=7}zWpcwnLHsgEKmdftA#7=32OzWs!BC+j^ilVL+WdMy zG6oh#cE%tuHynxt4zRVeG0-)~1FkKF$H@-oLVz6bE-QgJ#K0gh_zPksLy)Kv-XGtM zfMZbiB96x)t_TJh+5Y<+PLPrfo`x219sPg*_Eio9c|eZ!S00Q&z@bVuAmA7ngdGmy z1R=PP><|R7Qy&CkK@&wYN3%pTMKeJIv4WIL0SKV0UxC3ueaPS`akl-H�~pK&QAe?a6;I*IblZ_g8VQb2$b_1IX?#o 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 W`S}Y({+M2a=^UF$QBl!1Gx&d~PJ2fH literal 0 HcmV?d00001 diff --git a/tests/data/ttf/color/TestEmoji-COLRv1.ttf b/tests/data/ttf/color/TestEmoji-COLRv1.ttf index c61551e7c089b2cd1991f9425bad9703a7edc1d4..ebe2aa79537da97e85883eec641bb9602bc72b45 100644 GIT binary patch delta 701 zcmXw1O-NKx7(L&8GjCpR<~@IE)Ny8VY(^JFkxr08i`NJVDmWqt z3*+rXTneHDZZc>ik>Da)Mzm>FxhNMho4|OTd!2c3&b{~h&i%e~@4Fwbel3hZ0C-q| zh3eS!)Qo@YUj<0M0<3Ri)%#QF2e}f^_L!>L`0QL|r|9L@*VV&r^R9xqDU5n zZn-QCSupFk;e_E!DIh`bE~J4X@beLnsc%G)o)A@CrCBG*CieMK4shagED3poewQ&6 z9U!IaA0nVyw~hrU04mS{&gscb;eO;H$_m*vmI=A8018AI zzX%gI>WpSsPm!P&k`iVfY~zU0sy3!wM%4XqBBJb?!R&YvX+02^Xww503<=@2DG9&V M4ob*8+?C+hAFUBN-v9sr diff --git a/tests/data/ttf/color/build.py b/tests/data/ttf/color/build.py index 4973851f5..0c87023e3 100644 --- a/tests/data/ttf/color/build.py +++ b/tests/data/ttf/color/build.py @@ -9,7 +9,7 @@ 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-COLRv1.ttf glyf outlines, COLR version 1 paints of every kind (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 @@ -38,6 +38,7 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables.DefaultTable import DefaultTable from fontTools.ttLib.tables._g_l_y_f import Glyph, GlyphComponent +from fontTools.ttLib.tables.otTables import CompositeMode, ExtendMode from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph from fontTools.ttLib.tables.sbixStrike import Strike from fontTools.misc.timeTools import timestampFromString @@ -266,6 +267,8 @@ def build_colrv0(style='COLRv0', features=FEATURES): def build_colrv1(): + """Every glyph as version 1 paints, each emoji showing another part of what version 1 can say, and + beside them the version 0 records a renderer that knows only those falls back to.""" fb = base_font() name_font(fb, 'COLRv1') @@ -275,27 +278,80 @@ def solid(index, alpha=1.0): def glyph(layer, paint): return {'Format': 10, 'Glyph': layer, 'Paint': paint} - paints = {} - for name, _, layers in EMOJI: - paints[name] = {'Format': 1, 'Layers': [glyph(layer, solid(colour)) for layer, colour in layers]} - # One radial and one linear gradient, so a version 1 font carries what version 0 cannot say - paints['u1F600'] = {'Format': 1, 'Layers': [ - glyph('layer.face', {'Format': 6, 'ColorLine': {'ColorStop': [ - {'StopOffset': 0.0, 'PaletteIndex': WHITE}, {'StopOffset': 1.0, 'PaletteIndex': YELLOW}]}, - 'x0': 400, 'y0': 450, 'r0': 0, 'x1': 500, 'y1': 350, 'r1': 450}), - glyph('layer.eyes', solid(BLACK)), - glyph('layer.mouth', solid(FOREGROUND)), - ]} - paints['u1F1E6_u1F1FA'] = {'Format': 1, 'Layers': [ - glyph('layer.square', {'Format': 4, 'ColorLine': {'ColorStop': [ - {'StopOffset': 0.0, 'PaletteIndex': BLUE}, {'StopOffset': 1.0, 'PaletteIndex': GREEN}]}, - 'x0': 50, 'y0': 0, 'x1': 950, 'y1': 0, 'x2': 50, 'y2': 900}), - glyph('layer.stripe', solid(WHITE)), - ]} - # Every glyph as version 1 paints, and beside them the version 0 records a renderer that knows only - # those falls back to. buildCOLR would move every paint version 0 can express into the version 0 - # records and out of the paints, so the two are built apart and put together. - colr = buildCOLR(paints, version=1, glyphMap=fb.font.getReverseGlyphMap()) + def layers(*paints): + return {'Format': 1, 'Layers': list(paints)} + + def line(stops, extend=ExtendMode.PAD): + return {'Extend': extend, 'ColorStop': [ + {'StopOffset': offset, 'PaletteIndex': index, 'Alpha': alpha} for offset, index, alpha in stops]} + + def composite(mode, source, backdrop): + return {'Format': 32, 'CompositeMode': mode, 'SourcePaint': source, 'BackdropPaint': backdrop} + + eyes = glyph('layer.eyes', solid(BLACK)) + paints = { + # A radial gradient, and a layer in the colour of the text + 'u1F600': layers( + glyph('layer.face', {'Format': 6, 'ColorLine': line([(0.0, WHITE, 1.0), (1.0, YELLOW, 1.0)]), + 'x0': 400, 'y0': 450, 'r0': 0, 'x1': 500, 'y1': 350, 'r1': 450}), + eyes, + glyph('layer.mouth', solid(FOREGROUND))), + # A linear gradient kept to the heart by SRC_IN, and a half-opaque highlight + 'uni2764': layers( + composite(CompositeMode.SRC_IN, + glyph('layer.square', {'Format': 4, 'ColorLine': line([(0.0, RED, 1.0), (1.0, YELLOW, 1.0)]), + 'x0': 500, 'y0': 750, 'x1': 500, 'y1': -50, 'x2': 900, 'y2': 750}), + glyph('layer.heart', solid(RED))), + glyph('layer.shine', solid(HALF_WHITE))), + # Scaled uniformly about the face's centre + 'u1F468': {'Format': 22, 'scale': 0.8, 'centerX': 500, 'centerY': 350, + 'Paint': layers(glyph('layer.face', solid(BLUE)), eyes)}, + # A reflected linear gradient whose alpha varies, and whose stops the font lists out of order + 'u1F469': layers( + glyph('layer.face', {'Format': 4, 'ColorLine': line([(1.0, RED, 0.25), (0.0, RED, 1.0)], ExtendMode.REFLECT), + 'x0': 400, 'y0': 0, 'x1': 600, 'y1': 0, 'x2': 400, 'y2': 200}), + eyes), + # A repeated radial gradient: rings + 'u1F467': layers( + glyph('layer.face', {'Format': 6, 'ColorLine': line([(0.0, GREEN, 1.0), (1.0, WHITE, 1.0)], ExtendMode.REPEAT), + 'x0': 500, 'y0': 350, 'r0': 0, 'x1': 500, 'y1': 350, 'r1': 120}), + eyes), + # An affine transform: turned a little and moved + 'u1F1E6': {'Format': 12, 'Transform': {'xx': 0.9, 'yx': 0.1, 'xy': -0.1, 'yy': 0.9, 'dx': 80, 'dy': 0}, + 'Paint': glyph('layer.square', solid(BLUE))}, + # Another colour glyph drawn inside this one, and a stripe over it + 'u1F1FA': layers({'Format': 11, 'Glyph': 'u1F1E6'}, glyph('layer.stripe', solid(RED))), + # Skewed, then rotated + 'u1F3F4': {'Format': 24, 'angle': -10, 'Paint': {'Format': 28, 'xSkewAngle': 15, 'ySkewAngle': 0, + 'Paint': glyph('layer.pole', solid(BLACK))}}, + # A sweep gradient, which mPDF draws in the colour of its middle stop + 'u1F3FD': glyph('layer.square', {'Format': 8, 'ColorLine': line([(0.0, SKIN, 1.0), (0.5, RED, 1.0), (1.0, SKIN, 1.0)]), + 'centerX': 500, 'centerY': 350, 'startAngle': 0, 'endAngle': 360}), + # Turned about its centre, and cut off by its clip box + 'u1F44D': {'Format': 26, 'angle': 20, 'centerX': 500, 'centerY': 375, 'Paint': glyph('layer.thumb', solid(YELLOW))}, + # The faces multiplied onto a yellow square + 'u1F468_200D_u1F469_200D_u1F467': composite( + CompositeMode.MULTIPLY, + layers(glyph('layer.small.left', solid(BLUE)), glyph('layer.small.middle', solid(GREEN)), + glyph('layer.small.right', solid(RED))), + glyph('layer.square', solid(YELLOW))), + 'u1F1E6_u1F1FA': layers( + glyph('layer.square', {'Format': 4, 'ColorLine': line([(0.0, BLUE, 1.0), (1.0, GREEN, 1.0)]), + 'x0': 50, 'y0': 0, 'x1': 950, 'y1': 0, 'x2': 50, 'y2': 900}), + glyph('layer.stripe', solid(WHITE))), + # The digit cut out of the key by DEST_OUT + 'one_uni20E3': composite(CompositeMode.DEST_OUT, glyph('one', solid(BLACK)), glyph('layer.square', solid(GREY))), + # Moved, then scaled across + 'u1F44D_u1F3FD': {'Format': 14, 'dx': 100, 'dy': 0, 'Paint': { + 'Format': 16, 'scaleX': 0.8, 'scaleY': 1.0, 'Paint': glyph('layer.thumb', solid(SKIN))}}, + # A Var paint, read as the paint it varies + 'u1F3F4_E0067_E0062_E0065_E006E_E0067_E007F': layers( + glyph('layer.square', {'Format': 3, 'PaletteIndex': WHITE, 'Alpha': 1.0, 'VarIndexBase': 0xFFFFFFFF}), + glyph('layer.cross', solid(RED))), + } + # buildCOLR would move every paint version 0 can express into the version 0 records and out of the + # paints, so the two are built apart and put together + colr = buildCOLR(paints, version=1, glyphMap=fb.font.getReverseGlyphMap(), clipBoxes={'u1F44D': (50, -100, 950, 600)}) populateCOLRv0(colr.table, colr_layers(), fb.font.getReverseGlyphMap()) fb.font['COLR'] = colr 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])