Si tu as ouvert une heatmap Matomo et trouve ton hero en Times New Roman, tes boutons en Arial et les marqueurs de clics qui flottent quelques pixels a cote des elements auxquels ils appartiennent, les clics eux-memes sont corrects. Matomo les a enregistres aux bonnes coordonnees. Ce qui est casse, c'est la capture d'ecran en dessous. Tes polices de marque ne se sont pas chargees quand le serveur Matomo a re-rendu la page, alors il s'est rabattu sur les polices systeme disponibles. Une police differente signifie des metriques differentes, ce qui entraine des decalages de mise en page.
La correction la plus rapide est une extension Chrome gratuite qu'on maintient, Matomo Heatmap Helper. Elle embarque chaque police de la page en base64 juste avant chaque capture de heatmap, sans modification de feuille de style. Le reste de cet article explique comment faire la meme chose sans extension, et (plus bas) pourquoi Matomo ne peut pas charger tes polices en premier lieu.
Comment corriger ca sans l'extension
Il y a un snippet de console rapide qui contourne le probleme juste avant chaque capture, et quelques correctifs permanents que tu peux livrer avec le site. Choisis ce qui convient a ta stack. Il y a aussi un pattern JS a la fin qui casse la capture peu importe le correctif que tu livres — vaut mieux le connaitre dans tous les cas.
Diagnostiquer ce qui echoue vraiment
Avant de changer quoi que ce soit, identifie quelles polices ne se chargent pas. Ouvre la page dans Chrome, appuie sur F12 pour ouvrir DevTools, passe dans la Console et colle :
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
[...document.fonts].forEach(f =>
console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});Tout ce qui n'a pas un statut loaded est un candidat. Ensuite, DevTools → Network → filtre par "Font" et surveille les erreurs 403 ou CORS sur tes URLs @font-face. Ca te dit si tu as affaire a un probleme de CDN, d'Adobe Fonts, ou de serialisation.
La correction rapide : embarquer toutes les polices en base64 depuis la console
C'est le snippet qu'on colle dans la console du navigateur juste avant de declencher la capture de heatmap de Matomo. Il parcourt chaque regle @font-face de la page (inline, same-origin et feuilles de style cross-origin), recupere les binaires des polices, les encode en base64 et injecte une nouvelle balise <style> avec des data URIs auto-contenues. Au moment ou Matomo serialise le DOM, les polices sont integrees dedans.
// Paste into the browser console. Embeds every @font-face on the page as base64.
(async () => {
const fontFaces = [];
const parseSrc = (srcValue, baseUrl) => {
const out = [];
const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
let m;
while ((m = re.exec(srcValue)) !== null) {
const raw = m[1], format = m[2];
if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
}
return out;
};
const collect = (rule, baseUrl) => {
if (rule instanceof CSSFontFaceRule) {
const s = rule.style;
const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
const src = s.getPropertyValue('src');
if (!family || !src) return;
fontFaces.push({
family,
weight: s.getPropertyValue('font-weight') || '',
style: s.getPropertyValue('font-style') || '',
display: s.getPropertyValue('font-display') || '',
unicodeRange: s.getPropertyValue('unicode-range') || '',
src: parseSrc(src, baseUrl),
});
} else if (rule.cssRules) {
for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
}
};
// 1. Walk accessible stylesheets via the CSSOM
const crossOriginUrls = [];
for (const sheet of Array.from(document.styleSheets)) {
const baseUrl = sheet.href || location.href;
try {
for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
} catch {
if (sheet.href) crossOriginUrls.push(sheet.href);
}
}
// 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks
const parseCssText = (txt, baseUrl) => {
const re = /@font-face\s*\{/gi;
let m;
while ((m = re.exec(txt)) !== null) {
const start = m.index + m[0].length;
let depth = 1, i = start;
while (depth > 0 && i < txt.length) {
if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
i++;
}
if (depth !== 0) continue;
const body = txt.substring(start, i - 1);
const get = n => {
const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
return r ? r[1].trim() : '';
};
const family = get('font-family').replace(/['"]/g, '').trim();
const src = get('src');
if (!family || !src) continue;
fontFaces.push({
family,
weight: get('font-weight'),
style: get('font-style'),
display: get('font-display'),
unicodeRange: get('unicode-range'),
src: parseSrc(src, baseUrl),
});
}
// Recurse into @import
const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
const imports = [];
while ((m = importRe.exec(txt)) !== null) {
const u = m[1] || m[2];
try { imports.push(new URL(u, baseUrl).href); } catch {}
}
return imports;
};
const visited = new Set();
const fetchCss = async (url) => {
if (visited.has(url)) return;
visited.add(url);
try {
const txt = await fetch(url).then(r => r.text());
const imports = parseCssText(txt, url);
for (const u of imports) await fetchCss(u);
} catch (e) { console.warn('CSS fetch failed:', url, e.message); }
};
for (const url of crossOriginUrls) await fetchCss(url);
if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
console.log(`Found ${fontFaces.length} @font-face rules.`);
// 3. Fetch each unique font binary and base64-encode it
const uniqueUrls = [...new Set(
fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
)];
console.log(`Fetching ${uniqueUrls.length} font files...`);
const dataUris = new Map();
await Promise.all(uniqueUrls.map(async (url) => {
try {
const blob = await fetch(url, { mode: 'cors' }).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.blob();
});
const dataUri = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(blob);
});
dataUris.set(url, dataUri);
} catch (e) { console.warn('Font fetch failed:', url, e.message); }
}));
// 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
const css = fontFaces.map(f => {
const srcParts = f.src.map(s => {
const u = dataUris.get(s.url) || s.url;
return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
});
if (srcParts.length === 0) return '';
const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
if (f.weight) props.push(`font-weight: ${f.weight}`);
if (f.style) props.push(`font-style: ${f.style}`);
if (f.display) props.push(`font-display: ${f.display}`);
if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
return `@font-face {\n ${props.join(';\n ')};\n}`;
}).filter(Boolean).join('\n\n');
// 5. Inject into <head> so Matomo's DOM serializer sees self-contained fonts
const style = document.createElement('style');
style.setAttribute('data-heatmap-font-fix', 'true');
style.textContent = css;
document.head.appendChild(style);
console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} fonts across ${fontFaces.length} @font-face rules.`);
console.log('Now trigger your Matomo heatmap capture.');
})();Pour confirmer que l'injection a bien eu lieu avant de prendre la capture :
// Confirms the injected styles are in <head>
const tag = document.querySelector('style[data-heatmap-font-fix]');
console.log(tag
? `OK, injected ${(tag.textContent.match(/@font-face/g) || []).length} @font-face rules, `
+ `${(tag.textContent.match(/data:font/g) || []).length} use base64 data URIs.`
: 'No injected style tag found, re-run the embed snippet.');C'est le chemin le plus rapide. Ca marche pour les captures ponctuelles et les cycles de review ou tu as besoin d'une capture propre maintenant sans attendre un changement de code. Pour tout ce que tu vas recapturer regulierement, livre l'un des correctifs permanents ci-dessous.
Auto-heberger les polices (la correction canonique)
La reponse la plus propre pour presque tout le monde. Telecharge les variantes woff2 et woff que tu utilises vraiment depuis google-webfonts-helper, depose-les dans /public/fonts, et livre ce CSS :
/* Edit the family name and file paths to match your downloaded files */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-400.woff2") format("woff2"),
url("/fonts/inter-400.woff") format("woff");
}Les polices viennent maintenant de ton propre domaine. Meme origine que la page, pas de CORS a negocier, le serveur Matomo les recupere sans probleme, et tes captures s'affichent dans la bonne police. C'est aussi plus rapide pour tes vrais utilisateurs, parce que tu as supprime une resolution DNS et une connexion tierce de chaque chargement de page. La plupart du temps, c'est quelques heures de travail et un petit diff CSS.
Corriger le CORS sur ton CDN
Si tu preferes garder les polices sur un CDN, configure-le pour retourner Access-Control-Allow-Origin: * sur les reponses de polices, et ajoute crossorigin sur le lien de preload :
<!-- Edit the URL to your CDN's font path -->
<link rel="preload" as="font" type="font/woff2"
href="https://your-cdn.example.com/inter.woff2"
crossorigin="anonymous">Ca marche pour Cloudflare, Fastly, AWS, tout ce que tu controles. Ca ne marche pas pour Adobe Fonts. La liste de domaines autorises la-bas est appliquee cote serveur et tu ne peux pas la modifier. Si tu es sur Typekit, l'auto-hebergement est la seule option. La licence Adobe sur l'auto-hebergement est un autre sujet.
Integrer l'embed dans ton build
Si tu ne controles pas la feuille de style mais que tu controles le build, lance la meme logique que le snippet de console, mais au moment du build. Demarre un navigateur headless (Playwright ou Puppeteer), execute le code d'embed sur ta page buildee, capture le bloc <style> resultant et ecris-le dans ton template HTML. La page est livree avec les polices deja embarquees. Pas de fetch au runtime, pas de collage en console avant chaque capture.
C'est le plus lourd des trois correctifs permanents. Il couvre aussi le cas ou le site marketing est sur un CMS qui ne te laisse pas toucher a la feuille de style mais qui te permet de livrer un script de build.
Le piegeage de la serialisation
Une derniere chose qui prend les gens de court. Les polices chargees via JS au runtime ne survivent pas a la serialisation du DOM par Matomo :
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);La police est enregistree dans le set document.fonts du navigateur, mais elle n'est pas dans le HTML que le serialiseur lit. La capture se rabattra sur les polices systeme meme si la police est techniquement "chargee" dans ton navigateur. Idem pour les polices injectees par des frameworks JS au moment de l'hydratation. Utilise une balise <style> avec des regles @font-face, ou un <link rel="stylesheet"> classique. Tout ce que le serialiseur peut voir dans le HTML statique convient. document.fonts.add() lui est invisible.
Pourquoi Matomo ne peut pas charger tes polices
La capture d'ecran de Matomo est un processus en deux etapes. D'abord, le tracker serialise ton DOM live en HTML et l'envoie. Ensuite, ton serveur Matomo re-rend ce HTML pour produire la capture que tu vois dans la vue heatmap. Pas de session navigateur, pas de cookies, pas de domaine. Juste un fichier HTML rendu a froid depuis une IP differente.
Tout ce qui dans ce HTML pointe vers un host tiers doit passer le filtre de ce tiers. Pour les declarations @font-face, c'est la que ca s'arrete.
Ce qui echoue concretement
Quelques patterns qu'on croise regulierement :
- Le CDN qui sert tes polices ne retourne pas d'en-tetes
Access-Control-Allow-Origin. Le navigateur tolere le cas same-origin sans eux. Le serveur Matomo, qui fait la requete depuis une origine differente, ne le tolere pas. - Adobe Fonts (Typekit) valide les en-tetes
OriginetRefererpar rapport aux domaines que tu as autorises dans ton compte. Le serveur Matomo n'est jamais sur cette liste, et Typekit repond par un 403. Il n'y a pas moyen d'ajouter l'IP de Matomo non plus. fonts.gstatic.comet quelques autres CDN limitent le debit ou bloquent carrrement les renderers cote serveur, parfois par user-agent, parfois par reputation IP. Les polices fonctionnent dans ton navigateur mais pas dans le renderer de Matomo.- Les polices chargees avec
new FontFace(...).load()puisdocument.fonts.add(...)ne sont pas du tout dans le HTML serialise. Le serialiseur lit le DOM, pas le set FontFace au runtime.
Si ta capture de heatmap affiche la mauvaise police, tu touches au moins un de ces cas. Souvent plusieurs a la fois.
C'est aussi la meme famille de problemes qui casse les images, les conteneurs avec defilement et les en-tetes sticky dans la meme capture. On a ecrit un article plus long sur les captures de heatmaps Matomo cassees qui passe en revue le reste.
Ce qu'on ferait vraiment
Si tu controles la feuille de style, auto-heberge. Ca corrige la heatmap, supprime une dependance tierce, et accelere aussi le chargement de la page pour tes vrais utilisateurs.
Si tu ne peux pas, embarque les polices en data URIs base64 dans ta feuille de style existante. Meme effet sur le renderer de Matomo, pas de changements d'infrastructure.
Si aucune des deux options n'est accessible, l'extension Chrome Matomo Heatmap Helper est ce qu'on utilise sur les sites clients ou on ne peut pas modifier la stack. Elle lance la meme logique d'embed que le snippet ci-dessus, plus des correctifs equivalents pour les images, les conteneurs avec defilement et les en-tetes sticky, a chaque capture. Gratuite, open source, code sur GitHub.
Martez est le projet plus large dont l'extension est issue. Il connecte Matomo avec Meta Ads et Google Ads pour que le ROAS, la CLV et l'attribution soient au meme endroit que tes web analytics, et pas dans un tableur separe. Il est en beta privee. Rejoins la liste d'attente si ca te parle.
Les polices sont generalement corrigeables dans une feuille de style. Ca vaut le coup de le faire une bonne fois.