JustBrowser
Tutorials12 min read

Canvas Winding Rules and TextMetrics: The Sub-Signals Naive Canvas Spoofs Forget

JustBrowser Platform Team·
canvas-fingerprintingtextmetricsantidetect-browserwinding-rulefont-metricsbuildinpublicsaasstudioaiworkforcebuildwithclaude

I spent two weeks convinced my canvas fingerprint was clean. The hash matched across sessions. BrowserLeaks showed a generic Intel UHD 620 render. Even CreepJS gave me a passing grade on the canvas section. I was feeling pretty smug about it, honestly.

Then I wrote a test script that called measureText on a 48px Arial string and logged the actualBoundingBoxAscent value.

43.7998046875.

That's what my "Windows 11" profile returned. Actual Windows 11 machines return 44.0. macOS returns 43.23046875. My spoofed profile was sitting in no-man's-land — close enough to look intentional, different enough to be fingerprinted. Two weeks of smugness, vaporized. The canvas image was spoofed. The canvas text metrics were my real host machine leaking through like a bad poker tell.

This is the gap nobody talks about. Your antidetect browser spoofs toDataURL. Maybe it adds per-frame noise. Maybe it swaps the renderer string. But unless it's modifying the font rendering pipeline itself, every call to measureText broadcasts your actual operating system's typography stack. And isPointInPath winding behavior? Same story. Different codepath. Different leak.

Check your own fingerprint first. We built a public checker at justbrowser.app/fingerprint-checker — 17 rows, client-side, no signup. It grades what it claims rather than guessing: WebRTC candidates are compared against your public IP, and the IP's location is graded on whether its UTC offset actually agrees with your browser's timezone. Worth running before you read the rest of this, so the signals below have your numbers attached to them.

What We're Learning

By the end of this walkthrough, you'll understand:

  1. How isPointInPath winding rules create a stable fingerprint independent of canvas image hashing
  2. Why TextMetrics properties like actualBoundingBoxAscent and fontBoundingBoxDescent vary by OS
  3. Why per-frame canvas noise doesn't touch these signals
  4. How to test your antidetect profiles for these specific leaks
  5. What native engine modification looks like vs JavaScript injection

You'll also have a test snippet you can run against your own profiles to see if you're exposed.

Prerequisites

  • An antidetect browser with at least one configured profile
  • Basic JavaScript (enough to paste code into devtools)
  • Understanding of canvas fingerprinting basics — if you haven't read about WebGL fingerprinting or run a CreepJS test, start there
  • 20 minutes

Step 1: Understanding the isPointInPath Winding Problem

Canvas contexts have a method called isPointInPath(x, y, fillRule) that returns true if a point falls inside a drawn path. The fillRule parameter accepts two values: "nonzero" and "evenodd". These are standard SVG winding rules that determine how self-intersecting or overlapping paths get filled.

Here's the thing: different browsers implement the edge cases slightly differently. Not the obvious cases — a point clearly inside or outside a simple rectangle returns the same result everywhere. But consider:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

// Self-intersecting path
ctx.beginPath();
ctx.moveTo(50, 0);
ctx.lineTo(100, 100);
ctx.lineTo(0, 40);
ctx.lineTo(100, 40);
ctx.lineTo(0, 100);
ctx.closePath();

// Check a point right on the intersection boundary
const result1 = ctx.isPointInPath(50, 50, 'evenodd');
const result2 = ctx.isPointInPath(50.001, 50, 'evenodd');

The exact boundary handling — subpixel coordinates, antialiasing interaction, floating-point rounding — varies. And because these are path operations (not pixel operations), canvas noise injection doesn't affect them at all. You could add 100 random pixels to your canvas image. The path math? Doesn't care. Doesn't even notice.

I've seen this signal used in production fingerprinting scripts. Nasty stuff. They draw a complex path with known problem areas, then probe 20-30 boundary points with both winding rules. The result pattern creates a stable hash that survives canvas image spoofing entirely. I'm honestly a little impressed by how clever it is, even as it ruins my day.

Step 2: The TextMetrics Leak Is Worse

The measureText method returns a TextMetrics object. Most fingerprinting discussions focus on the width property. But modern browsers expose much more:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '48px Arial';

const metrics = ctx.measureText('Sphinx of black quartz');

console.log({
  width: metrics.width,
  actualBoundingBoxAscent: metrics.actualBoundingBoxAscent,
  actualBoundingBoxDescent: metrics.actualBoundingBoxDescent,
  fontBoundingBoxAscent: metrics.fontBoundingBoxAscent,
  fontBoundingBoxDescent: metrics.fontBoundingBoxDescent,
  actualBoundingBoxLeft: metrics.actualBoundingBoxLeft,
  actualBoundingBoxRight: metrics.actualBoundingBoxRight
});

These values come directly from the font rasterizer. Windows uses DirectWrite. macOS uses Core Text. Linux typically uses FreeType with Fontconfig. Each system applies different hinting, different subpixel positioning, different glyph substitution rules. Arial on Windows looks almost like Arial on macOS — but the bounding box measurements differ by fractions of a pixel. Enough to fingerprint.

Here's what makes this brutal: measureText never touches the canvas bitmap. No pixels get drawn. No toDataURL gets called. Canvas noise injection operates on the pixel buffer. TextMetrics operates on the font shaper. Completely different pipelines. Spoofing one has zero effect on the other. It took me embarrassingly long to figure this out.

I tested this across five different antidetect browsers (I won't name the ones that failed — you can guess). Four of them perfectly spoofed canvas image hashes but leaked real TextMetrics values. The fifth — JustBrowser — restricts the font list to the spoofed platform's set at the engine level and adds per-profile noise to canvas text measurement, so the host font list does not leak through measureText. It does not claim to reproduce another OS's exact glyph metrics. Not JavaScript. Not extensions. The actual Chromium font stack.

Step 3: Testing Your Profile

Here's a test script. Open your antidetect profile, paste this into devtools, and compare the output against known-good values for your target OS.

function testCanvasSubAPI() {
  const canvas = document.createElement('canvas');
  canvas.width = 200;
  canvas.height = 200;
  const ctx = canvas.getContext('2d');

  // TextMetrics test
  ctx.font = '48px Arial';
  const metrics = ctx.measureText('Mmwxyz');

  // Winding rule test - self-intersecting star
  ctx.beginPath();
  for (let i = 0; i < 5; i++) {
    const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
    const x = 100 + 80 * Math.cos(angle);
    const y = 100 + 80 * Math.sin(angle);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.closePath();

  const windingResults = [];
  const testPoints = [[100, 100], [100, 60], [60, 80], [100, 40]];
  for (const [x, y] of testPoints) {
    windingResults.push({
      point: [x, y],
      nonzero: ctx.isPointInPath(x, y, 'nonzero'),
      evenodd: ctx.isPointInPath(x, y, 'evenodd')
    });
  }

  return {
    textMetrics: {
      width: metrics.width,
      ascent: metrics.actualBoundingBoxAscent,
      descent: metrics.actualBoundingBoxDescent,
      fontAscent: metrics.fontBoundingBoxAscent,
      fontDescent: metrics.fontBoundingBoxDescent
    },
    windingResults
  };
}

console.log(JSON.stringify(testCanvasSubAPI(), null, 2));

What to look for:

  • TextMetrics ascent/descent values: Compare against a real machine running your target OS. Windows 11 with Arial 48px typically shows actualBoundingBoxAscent around 44.0. macOS is closer to 43.2. If your spoofed Windows profile shows 43.2, you're leaking.

  • Winding consistency: The results should match real Chromium behavior. If your antidetect browser modifies path operations differently than stock Chromium, sophisticated fingerprinters will notice.

Run this on your actual host machine first. Then run it inside your antidetect profile. If the numbers match, your profile is leaking host signals. If they match the target OS values, you're probably okay — but test against CreepJS and FingerprintJS Pro anyway. Belt and suspenders.

Step 4: Why Extension-Based Spoofing Can't Fix This

Extension-based antidetect tools work by overriding JavaScript APIs. They intercept calls to canvas.toDataURL or ctx.getImageData and modify the return values. This works great for the specific functions they hook.

But measureText returns a TextMetrics object created deep inside the rendering engine. You can override the measureText function to return a fake object, sure. But then you need to fake consistent values that match a real OS. Every font. Every size. Every string. The combinatorial explosion is brutal — I tried building a lookup table once and gave up after 10,000 entries that still didn't cover common cases.

I've seen extension spoofs that return hardcoded TextMetrics values. They pass basic checks. Then someone calls measureText('Test') and measureText('Testing') and compares the width difference. If the delta doesn't match what a real system produces, you're flagged. The relationship between glyph widths needs to be internally consistent. Hardcoding breaks that.

Native engine modification — actually patching Chromium's Skia text shaper — handles this correctly. The engine produces genuine TextMetrics for a configured OS/font combination because the font stack itself is swapped out. No JavaScript overrides. No edge cases. The measurements are real; they're just from a different rendering profile.

This is why JustBrowser's native Chromium approach matters. Look, I'm not here to bash competitors — some of them are fine for basic use cases. But if you're doing anything serious, the layer where spoofing happens is everything. JavaScript can't fix what JavaScript can't reach. Period.

Step 5: Verification Patterns That Actually Work

For teams managing separate profiles for accounts they are allowed to hold (client accounts with permission, shared team profiles, QA):

  1. Test every profile template with the script above before deployment
  2. Compare results against known-good reference values for your target OS
  3. If your antidetect browser leaks TextMetrics, switch tools, because leaked host values break the isolation you configured. Check each platform's rules too: where a platform forbids multiple accounts, profiles are not a way around that, and its appeal route is the right path
  4. Monitor for fingerprinting script updates — the big players add new sub-API checks quarterly

For scraping engineers:

  1. If you're using Playwright/Puppeteer with a stealth plugin, check whether it covers TextMetrics. Most don't. The Playwright antidetect setup guide covers CDP-level integration.
  2. Consider running headful through JustBrowser's API rather than patching headless Chromium yourself — there's a lot of surface area to cover
  3. Cache your fingerprint profile and diff it weekly. If something changes, investigate before you're blocked.

For everyone:

The canvas fingerprint isn't one signal. It's dozens of signals across multiple APIs. Spoofing the image hash covers maybe 30% of the attack surface. That's my rough estimate — could be lower. TextMetrics, winding rules, gradient rendering differences, path stroking precision — each one is a distinct fingerprint vector. Treat canvas like you'd treat TLS fingerprinting: assume it's deep and assume cheap solutions miss stuff. I wish someone had told me this two years ago.

Common Errors

Error: TextMetrics values don't match any known OS

Cause: Your antidetect browser is either not spoofing TextMetrics at all (leaking host values) or spoofing them incorrectly (generating synthetic values that don't match real systems).

Fix: Switch to an antidetect browser with native engine font rendering modifications. If that's not an option, at least match your profile's claimed OS to your actual host OS — it's not ideal (I'd even say it's a hack), but it eliminates the most obvious mismatch.

Error: Winding rule results differ from stock Chromium

Cause: Some antidetect tools modify canvas path operations in ways that create detectable differences.

Fix: Test against a clean Chromium install. Your antidetect profile should produce identical winding results. If it doesn't, check if there's a setting to disable path modifications. If your tool doesn't support this, you're stuck.

Error: Test passes locally but the profile is still identified

Cause: Canvas sub-APIs are one of many signals. IP reputation, behavioral patterns, TLS fingerprinting, and session token binding all contribute.

Fix: Verify proxies separately. Run a full fingerprint audit, not just canvas. For tracking overall profile health, JustAnalytics can help spot anomalies in your own test traffic.

Next Steps

Now that you understand these sub-API vectors:

  1. Audit your existing profiles — Run the test script on every template you're using
  2. Check your antidetect browser's documentation — Does it claim native font stack spoofing? Test whether that claim holds
  3. Add TextMetrics to your verification workflow — If you're testing profiles against CreepJS already, add this check too
  4. Read the font rendering deep-dive — Font rendering and subpixel antialiasing covers the font stack in more detail

The teams who get reliable results are the ones who understand what they're actually configuring. Canvas noise is table stakes. TextMetrics, winding rules, and the dozen other sub-APIs — that's where the real detection happens. Most people never look. (I know because I was "most people" until this bit me.)

Now you have. Don't make my mistakes.

Frequently Asked Questions

What is canvas winding rule fingerprinting?

When you call isPointInPath on a canvas context, you can specify either 'nonzero' or 'evenodd' as the winding rule. Different browsers and rendering engines handle edge cases — like self-intersecting paths or subpixel boundary conditions — slightly differently. These micro-differences create a stable fingerprint signal that survives canvas image noise injection because the winding logic itself isn't affected by pixel manipulation.

How do TextMetrics leak operating system information?

The measureText method returns a TextMetrics object with properties like actualBoundingBoxAscent, actualBoundingBoxDescent, fontBoundingBoxAscent, and width. These values depend on your OS font rendering stack — hinting algorithms, subpixel positioning, glyph substitution tables. Windows, macOS, and Linux produce measurably different metrics for identical font+size combinations because they use different text shaping engines.

Why doesn't canvas noise injection block TextMetrics fingerprinting?

Canvas noise injection adds random offsets to rendered pixel data before the hash is computed. But TextMetrics values come from the font rasterizer before any drawing happens — they're measurements, not pixels. Injecting noise into toDataURL has zero effect on what measureText returns. The two APIs are completely separate, so spoofing one doesn't spoof the other.

How does JustBrowser handle these sub-API signals?

JustBrowser restricts the font list to the spoofed platform's set at the engine level and adds per-profile noise to canvas text measurement, so the host font list does not leak through measureText. It does not claim to reproduce another OS's exact glyph metrics. isPointInPath is left as stock Chromium behaviour, which is what you want — the winding results match a real Chromium build rather than a modified one. Because the font and canvas changes are engine-level, not JavaScript overrides, they stay internally consistent across canvas API methods.


Try JustBrowser

Native Chromium antidetect browser — not extension-based. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, so 40+ identity parameters are genuine, not faked. REST API for Playwright, Puppeteer, Selenium. $9.99/month or $99.99/year. 7-day free trial, card required — cancel any time in the seven days and you are not charged. Unlimited profiles.

Get started → · How it differs from Multilogin / GoLogin / AdsPower

Ready to manage multiple accounts?

Seven days free, then $9.99/month — one plan, everything included.

We'd like to use Google Analytics, a Google service, to understand how our website is used. It sets two cookies in your browser and runs only if you click Accept. You can change your choice at any time with Cookie settings. Cookie Policy

Sign-in cookies and the cookie that remembers this choice are always on; the website needs them to work.

Google Analytics, a Google service, helps us understand how our website is used. It sets two cookies, _ga and _ga_TVZHQ99TZW. It is now onoff in this browser. If your browser sends a Global Privacy Control or Do Not Track signal, it stays off. Cookie Policy