fix(stealth): perturb glyph advances only, not positioning offsets (#741)

The font-spacing randomisation in anti-font-fingerprinting.patch fed a
running total into each glyph's x_offset/y_offset as well as its advance:

    glyphPositions[i].x_advance += spacing;
    glyphPositions[i].x_offset  += cumulativeOffset;
    cumulativeOffset += spacing;

x_offset is the GPOS positioning offset -- the thing that parks a combining
mark over its base. It does not contribute to the measured width, so it adds
nothing to the metric the randomisation exists to perturb, but layout never
sees it either: gfxTextRun stores advances, while painting honours the
offsets. The running total therefore paints every glyph a further `spacing`
past where layout placed it, and detaches every combining mark from its base.

Latin hides this because Gecko's word cache shapes each space-delimited word
separately, so the accumulator resets constantly. Thai has no inter-word
spaces, so a whole paragraph is a single shaping run and the error grows
without bound. Measured on 152.0.4-beta.29 at the worst-case seed, painted
text overran its layout box by:

    Thai  1x run   4.90px      Latin  1x run   0.00px
    Thai  2x run   9.80px      Latin  2x run   0.00px
    Thai  4x run  20.60px      Latin  4x run   0.00px

which is the garbled Thai in #741 -- and, since the seed is randomised per
launch, why it looks like an intermittent regression rather than a bug.

Perturb advances only, and skip zero-advance glyphs: a combining mark is not
a separate character, and widening it pushes the following base away and
strands the mark. Every advancing glyph still gets +spacing, so the measured
width -- the actual fingerprinting signal -- is perturbed exactly as before
(verified: 4/8/16/32px by run length, unchanged).

After the fix the overrun is 0.00px for Thai at every run length.
This commit is contained in:
Jake Writer
2026-08-28 12:53:31 -06:00
parent 4edbcffdd3
commit ad697a88d9
+23 -9
View File
@@ -851,7 +851,7 @@ index e41408f51c..ffa0bd4caf 100644
#include <algorithm>
@@ -1465,6 +1469,36 @@ bool gfxHarfBuzzShaper::ShapeText(const char16_t* aText, uint32_t aOffset,
@@ -1465,6 +1469,50 @@ bool gfxHarfBuzzShaper::ShapeText(const char16_t* aText, uint32_t aOffset,
hb_shape(mHBFont, mBuffer, features.Elements(), features.Length());
@@ -870,18 +870,32 @@ index e41408f51c..ffa0bd4caf 100644
+ hb_glyph_position_t* glyphPositions =
+ hb_buffer_get_glyph_positions(mBuffer, &glyphCount);
+
+ hb_position_t cumulativeOffset = 0;
+
+ // Apply custom letter spacing
+ // Perturb ADVANCES only.
+ //
+ // x_offset/y_offset are the positioning offsets GPOS uses to place a glyph
+ // relative to the pen -- most importantly to park a combining mark over
+ // its base. They do not contribute to the measured width, so they add
+ // nothing to the metric we are perturbing, but layout never sees them:
+ // gfxTextRun stores advances, while painting honours the offsets. Feeding
+ // a running total into them paints every glyph a further `spacing` past
+ // where layout placed it and detaches every combining mark from its base.
+ // Scripts whose marks carry a zero advance -- Thai, Lao, Arabic,
+ // Devanagari, Hebrew -- collapse into stacked glyphs, while Latin merely
+ // looks slightly loose (daijro/camoufox#741).
+ //
+ // Zero-advance glyphs are skipped for the same reason: a combining mark is
+ // not a separate character, and widening it pushes the following base away
+ // and strands the mark. Every advancing glyph still gets +spacing, so the
+ // measured width -- the actual fingerprinting signal -- is perturbed
+ // exactly as before.
+ for (uint32_t i = 0; i < glyphCount; ++i) {
+ if (aVertical) {
+ glyphPositions[i].y_advance -= spacing;
+ glyphPositions[i].y_offset -= cumulativeOffset;
+ } else {
+ if (glyphPositions[i].y_advance != 0) {
+ glyphPositions[i].y_advance -= spacing;
+ }
+ } else if (glyphPositions[i].x_advance != 0) {
+ glyphPositions[i].x_advance += spacing;
+ glyphPositions[i].x_offset += cumulativeOffset;
+ }
+ cumulativeOffset += spacing;
+ }
+ }
+