tclpdf

Soft masks inside a Type 3 glyph — seven readers, two of them wrong, twice
Login

Soft masks inside a Type 3 glyph — seven readers, two of them wrong, twice

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:

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.