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 000000000..a790da7b0 Binary files /dev/null and b/tests/data/snapshots/color-emoji-colrv1.pdf differ diff --git a/tests/data/ttf/color/TestEmoji-COLRv1.ttf b/tests/data/ttf/color/TestEmoji-COLRv1.ttf index c61551e7c..ebe2aa795 100644 Binary files a/tests/data/ttf/color/TestEmoji-COLRv1.ttf and b/tests/data/ttf/color/TestEmoji-COLRv1.ttf differ 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])