A colour font drawn as Type 3 glyphs may need a gradient whose opacity changes along the way — the blush on an emoji face is exactly that. ISO 32000-2 offers one construct for it: a luminance soft mask, set with gs inside the glyph stream, its geometry anchored — says 11.6.5.1, in so many words — to "the current transformation matrix at the moment the soft mask is established". Inside a Type 3 glyph that CTM contains the font matrix and the text position, so the mask travels with the glyph. This package built it that way, and the construction is correct.
Correct, and not portable. The same page — a skin-tone ramp, six builders in a row — was opened in seven readers:
| Reader | Result |
|---|---|
| Acrobat Reader | ok |
| Safari (Quartz) | ok |
| Chrome (PDFium) | ok |
| Firefox (pdf.js) | ok |
poppler (pdftoppm) |
ok |
| macOS Preview (PDFKit) | faces vanish, worse to the right |
| PDF Gear | faces vanish, worse to the right |
The failure pattern is the diagnosis: the further right the glyph sits on the page, the further its masked gradient falls outside its mask. That is what it looks like when a reader anchors the mask's matrix to the page's CTM instead of the glyph's — the mask stays where the first glyph was, and every following glyph slides out from under it. Five independent renderers read the clause the way it is written; two do not, and one of the two is the default viewer of every Mac.
(An eighth program, Affinity Designer, showed a third picture: the right glyphs as the wrong characters, side by side. That is not a rendering fault — it is an importer, it replaces embedded fonts with system faces and re-sets the extracted text. Any PDF with its own fonts gets that treatment there.)
The consequence drawn
A writer cannot fix a reader, but it can prefer constructions no reader gets wrong. Both uses of the soft mask in this package's colour-font drawer have a plainer equivalent:
- A gradient with varying stop alphas is now drawn as nested bands of constant alpha. The obvious construction — abutting strips, each clipped and painted once under its own
ca— was measured first and rejected: two clipped regions that share an edge each cover the boundary pixel partly, and the two coverages composite over one another instead of adding up, so every seam came out 58 of 255 grey levels lighter than the band beside it. The bands are therefore level sets: the region where alpha is at least A contains the region for the next step, the same shading is painted over each with the alpha increment asca, and each clip narrows the one before it. A colour over itself is itself, only the alpha accumulates, and a boundary is now the edge of an increment over already painted ground — it interpolates instead of dipping. Step 1/64, measured at 400 dpi: 1/16 shows terraces, 1/32 faint ones on slow fades, 1/64 none, 1/128 costs twice the operators for nothing visible. Strips across a linear axis, even-odd rings for a nested radial (7894 of Noto's 7894 varying-alpha radials are nested), the wedges a sweep already has. A constant alpha and a clip path are the two most boring operators in PDF; no reader misplaces them. - A Source In composition has two plainer forms and one that has none. Where the masking side is a solid shape at one alpha, the mask is exactly a constant
caon the source's group; where it is a stack of opaque outlines, it is a clip path over their union,W nwith the source painted inside. Measured over the 316 Source In compositions of Noto Color Emoji: 54 take the constant alpha, 2 take the clip on the opaque stack alone, and 250 are a stack with one translucent shape laid over it — the shimmer on a waving flag. Those 250 were left on the mask at first, on the ground that their alpha is one inside the opaque union and the shape's own alpha outside it, so whether the mask is binary is a question about where two outlines lie and not about the paint graph. It is a question with an answer, and the answer was measured on 2026-08-26: rendered at 300 dpi, one page per stack, 227 of the 250 shapes lie inside and 23 stick out. Where the shape lies inside, the alpha is binary after all and the clip is exact. New quota over the 316: 54 constant alpha, 168 clip, 94 mask — 166 of the 250 reach the clip, 20 keep the mask because the shape sticks out, and 64 because of the paragraph below.
Two things had to be got right before that clip could be written, and the first one is easy to miss. W n takes ONE path, and two clips intersect rather than unite, so several outlines go into one path under the nonzero rule — which is their union only while none of them winds against another. Rendered at 300 dpi, one page per stack with one fill per outline for a true union: concatenating the 250 stacks as they stand differs from the union in 120 of them, by as much as 1.8 million pixels of a 2133 by 2133 page. The cure is to turn the outlines that run the wrong way round, which leaves the area each of them fills exactly where it was — and what decides "the wrong way" is not the signed area: one stripe of a flag crosses itself into two lobes of opposite winding whose areas all but cancel, measured at −50.9 square units inside a bounding box of a million. The winding rule is therefore asked directly, at every face of the outline's own arrangement, and an outline that winds both ways cannot be brought into line at all — 64 of the 250 hold one, and those keep the mask however their shapes lie. The second is that the shimmer's boundary does not lie near the flag's outline, it lies on it — the same control points, to the unit — so a test asking whether a vertex is inside a region would have answered by rounding on all 250. Both tests probe the FACES of the arrangement instead, from both sides of every edge at a millionth of the bounding diagonal, and both were checked against a rasteriser: 227 of the 250 shapes come out clean and 23 leave dark pixels whose area grows with the square of the resolution, and the geometry agrees with the pixels on every one of them: no shape that sticks out was ever called contained. The arithmetic is colorFontRegion.tcl.
The second finding, one page earlier
The skin-tone page was the page that got looked at, and the one that got fixed. The rebuilt example was then looked at again, this time on its first page, and the sequence line there — two families, four flags — showed the same two readers failing in a new way:
| Reader | Result |
|---|---|
| Acrobat Reader, Safari, Chrome, Firefox, poppler | ok |
| macOS Preview (PDFKit) | a grey box, the full glyph box, behind each flag |
| PDF Gear | the same box |
Those four flags were exactly the glyphs that still carried a soft mask — not a luminance mask this time but a Source In alpha mask, the shimmer of a waving flag limited to the flag's own shape. The box is that shimmer painted over the whole glyph box: the mask is not applied. Same construction family, same two readers, same conclusion, and it is worth stating why it was not caught with the first fault: a reader is checked per construction, not per document, and the first check had looked at a page that carried only the one construction. The flag 🏳️⚧️ is worth naming separately: its paint graph is six flat filled outlines, no gradient, no composite and no mask of any kind, so nothing this package writes for it can produce a box.
What remains, and what it costs
A luminance mask remains for what has no plainer form — a non-nested radial cone, a colour line that would need more than 256 regions, and the alpha masks above. The price is size: about 23 per cent more bytes per emoji, since the shading is painted once per band, and a slightly firmer anti-aliased contour where up to sixty-five paints through the same outline clip accumulate on a partially covered edge pixel — interiors are exact to half a step. The clip on the flags costs nothing at all: it takes bytes away, since a mask is a form XObject and an ExtGState and a clip is a path the glyph already draws. The page that found the fault, example 02.18-noto-color-emoji, is the regression test: SMask count in the written file 71 before the bands, 3 after them, 0 after the flags, 610 745 bytes against 615 077, and the pages render as they did — against poppler at 400 dpi the whole example moves by at most 22 of 255 grey levels on 208 pixels of 15.5 million, all of them on the anti-aliased edge where a clip replaces a mask, and 02.17 is identical to the byte. Against hb-view at 400 dpi, the four flags and ⚧ sit the same distance from the reference before and after, to four decimal places of RMSE. The readers that were wrong were checked by hand on the rebuilt pages: Preview and PDF Gear draw every face on the skin-tone page, and neither draws a box behind any flag on the first.
Three constructions still write a mask, and all three were put in front of PDFKit on 2026-08-26 — the reader that started this note — with a renderer built on the same framework Preview uses, at 400 dpi against poppler. The first is the one this note already describes: an alpha mask whose masking shape sticks out of the opaque union, where PDFKit draws about a quarter of the shape and poppler the whole of it (RMSE 0.41), and in Noto Color Emoji exactly one glyph of a twenty-five-sequence page is left in that state — 🇺🇳, u1F1FA_u1F1F3 — behind which PDFKit paints the grey box again. The second is the /TR-inverted alpha mask, which is what Source Out, Destination Out and XOR are built from: PDFKit punches out roughly a quarter of the shape that should have been cut away, RMSE 0.59, so an inverted mask is misplaced exactly as an uninverted one is and the transfer function changes nothing about it. The third is the luminance mask of a non-nested radial cone — a gradient whose two circles do not nest, so its bands would not be rings — where PDFKit draws nothing at all: not a box, not the cone, no ink on the page, while poppler draws the cone the file asks for. Neither of the last two occurs in Noto Color Emoji (it has no Source Out and all 7894 of its varying-alpha radials are nested), so no shipped example exercises them; they are named here because they are the same family as the fault above and a reader looking for what is left should find all three in one list rather than two.
What to take from it
The validators were unanimous and useless here: qpdf clean, veraPDF 0 failed checks, both before and after — a mask in the wrong place is valid PDF. hb-view agreed with the file, because the file was right. The only instrument that saw the fault was opening the page in the readers people actually use, plural. That is the measurement this note exists to recommend: a feature that touches transparency, masks or Type 3 is not accepted until the page has been looked at — not validated, looked at — in more than one reader.