Le projet : transformer du texte en vraie image
Septième projet, et une nouvelle corde à ton arc : le canvas. Jusqu'ici on assemblait des balises HTML ; cette fois on va dessiner, pixel par pixel, dans une zone dédiée. Le but : tu écris un message, il apparaît sur une jolie carte en dégradé, et un bouton te la télécharge en image PNG, prête à poster.
Pourquoi c'est un bon palier ? Parce que dessiner, c'est plus bas niveau qu'empiler du HTML : le canvas ne sait rien faire « tout seul ». Pas de retour à la ligne, pas de texte sélectionnable, rien pour un lecteur d'écran. C'est très exactement le terrain où l'IA produit un résultat joli sur la capture d'écran, mais bancal dès qu'on sort du cas idéal. On code vite, on relit lentement.
Un <canvas>, c'est une surface de dessin pilotée en JavaScript via un « contexte » (ctx). On lui donne des ordres : remplis ce rectangle, écris ce texte ici. À la fin, on peut exporter le tout en fichier image.
- Faire passer un texte long à la ligne dans un canvas : mesurer chaque mot avec
measureTextet construire toi-même la boucle de wrap, parce que le canvas n'en a aucune notion. - Rendre une image canvas nette sur un écran Retina : multiplier la résolution interne par
window.devicePixelRatio, puis rééchelonner le contexte avecctx.setTransform. - Rendre accessible un canvas : le texte dessiné est muet pour un lecteur d'écran, donc on lui attache
role="img"et unaria-labelqui reflète le message courant.
- un texte long passe à la ligne au lieu de déborder du cadre ;
- l'image téléchargée est nette sur mobile Retina, pas pixelisée ;
- le canvas a une alternative textuelle lue par les lecteurs d'écran.
Prompt 1 : poser le cadre
On cadre : un champ de message, un canvas qui dessine une carte avec un dégradé, et un bouton pour télécharger l'image.
Crée une mini-app web autonome, un seul fichier HTML (zéro dépendance). Un champ texte ; le message s'affiche sur une carte dessinée dans un <canvas> avec un fond en dégradé et du texte blanc centré. Un bouton télécharge la carte en PNG. Bilingue FR/EN. Code simple et lisible.
L'IA dessine la carte. Mais son code de dessin cache trois manques :
canvas.width = 800; canvas.height = 450; // ⚠️ taille fixe : image floue sur écran Retina
var ctx = canvas.getContext('2d');
ctx.fillStyle = grad; ctx.fillRect(0, 0, 800, 450);
ctx.fillStyle = '#fff'; ctx.font = '40px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(message, 400, 225); // ⚠️ une seule ligne : un long message déborde
Tape un mot, c'est parfait. Tape une phrase, elle sort de la carte par les côtés. Et l'image téléchargée est curieusement floue sur un téléphone récent.
Relis ce code IA sans descendre plus bas. L'IA écrit le message en une seule instruction : ctx.fillText(message, 400, 225). À ton avis, que se passe-t-il avec une phrase de 15 mots ? Le canvas va-t-il passer à la ligne tout seul ?
ctx.font = '40px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(message, 400, 225); // le message, d'un seul coup
Vérifier ma prédiction
Non, le canvas ne passe jamais à la ligne tout seul. fillText écrit tout sur une seule ligne, quelle que soit la longueur : un texte un peu long sort simplement du cadre, ou est coupé si la propriété maxWidth est passée (mais dans ce cas le texte est écrasé horizontalement, pas découpé proprement). Il faut mesurer chaque mot avec ctx.measureText(), accumuler les mots sur la ligne courante, et passer à la suivante dès qu'on dépasse la largeur disponible. C'est une petite boucle entièrement à notre charge.
Ma relecture humaine : 3 trucs que l'IA a laissés passer
Le canvas, c'est puissant mais primitif : il fait exactement ce qu'on lui dit, rien de plus. Tout ce que le HTML offrait gratuitement (retour à la ligne, accessibilité), il faut ici le rajouter à la main. Voici les trois points.
1. Le canvas ne va pas à la ligne tout seul
fillText écrit le texte sur une seule ligne, point. Un message un peu long déborde tout simplement de la carte. Il faut découper le texte soi-même : on mesure les mots un par un avec ctx.measureText(), et on passe à la ligne dès qu'on dépasse la largeur disponible. C'est une petite boucle, mais c'est à toi de l'écrire, le canvas ne le fera jamais.
2. L'image est floue sur les écrans Retina
Un canvas de 800×450 « pixels » sur un écran haute densité (téléphone, Mac récent), c'est étiré sur deux fois plus de pixels réels : le résultat est flou. La parade : multiplier la résolution interne par window.devicePixelRatio, puis appliquer la même échelle au dessin (ctx.setTransform). On dessine toujours en « points » logiques, mais l'image exportée est nette.
3. Un canvas est muet pour un lecteur d'écran
Le texte dessiné dans un canvas n'existe pas vraiment : c'est de la peinture, pas du texte. Un lecteur d'écran ne « voit » qu'une image vide. On corrige en deux temps : le vrai champ reste un <textarea> (accessible, c'est la source), et on donne au canvas role="img" + un aria-label qui reflète le message courant. L'aperçu redevient compréhensible pour tout le monde.
Prompt 2 : durcir après relecture
Trois manques, trois corrections demandées précisément :
Corrige : (1) découpe le texte en plusieurs lignes avec measureText pour qu'un long message ne déborde pas. (2) Tiens compte de window.devicePixelRatio (canvas.width = W * dpr, puis ctx.setTransform) pour une image nette sur écran Retina. (3) Garde un <textarea> comme champ accessible, et mets role="img" + un aria-label reflétant le texte sur le canvas.
// 2. netteté Retina : on dessine en plus haute résolution
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr; canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // on raisonne ensuite en points logiques
// 1. retour à la ligne : on mesure mot par mot
function wrapText(text, maxWidth) {
var words = text.split(' '), lines = [], line = '';
for (var i = 0; i < words.length; i++) {
var test = line ? line + ' ' + words[i] : words[i];
if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = words[i]; }
else line = test;
}
if (line) lines.push(line);
return lines;
}
// 3. accessibilité : le texte dessiné est aussi annoncé
canvas.setAttribute('aria-label', text);
La règle de fond, encore valable ici : l'outil ne fait que ce qu'on lui dit. Le HTML t'offrait des cadeaux (le navigateur gérait le texte et l'accessibilité). Le canvas, lui, te rend responsable de tout. Plus on descend en bas niveau, plus la relecture humaine compte.
Héberger et tester
Fichier statique, en ligne d'un dépôt. Côté tests, on garde les réflexes habituels, plus ceux propres au canvas :
- Tape une phrase longue : elle doit passer sur plusieurs lignes, jamais déborder.
- Télécharge l'image et ouvre-la : sur mobile/Retina, le texte doit être net, pas pixelisé.
- Change de fond plusieurs fois : le texte blanc reste lisible (les fonds sont volontairement sombres).
- Navigue au clavier jusqu'au champ ; vérifie la bascule FR / EN et la console (zéro erreur).
Le rendu final
Le code complet (et téléchargeable)
Le fichier entier, exactement celui qui tourne au-dessus.
Télécharger le code (.html · 236 lignes)
Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte en image (Canvas)</title>
<meta name="description" content="Écris un message, génère une carte et télécharge-la en image. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
CARTE EN IMAGE — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (le canvas, le champ texte, les boutons).
2. <body> : la structure (champ + aperçu canvas + actions).
3. <script> : dessiner sur le canvas et exporter l'image en PNG.
Nouveauté du projet : le <canvas>, une zone où l'on DESSINE en JavaScript
(au lieu d'empiler des balises HTML). Trois pièges classiques à corriger :
le retour à la ligne (le canvas ne le fait pas tout seul), la netteté sur
écran Retina, et l'accessibilité (un canvas est une image muette).
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:560px; }
header { text-align:center; margin-bottom:20px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
/* Le canvas s'affiche en pleine largeur ; sa résolution interne est plus haute (Retina). */
canvas { width:100%; height:auto; display:block; border-radius:16px; box-shadow:0 16px 38px rgba(15,25,35,0.16); }
textarea { width:100%; margin-top:18px; min-height:80px; padding:12px 14px; border:1.5px solid var(--border); border-radius:12px; font:inherit; font-size:1rem; color:var(--ink); resize:vertical; }
textarea:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.actions { display:flex; gap:12px; margin-top:14px; flex-wrap:wrap; }
.btn { flex:1 1 auto; min-width:150px; min-height:50px; border-radius:12px; border:1.5px solid transparent; font:inherit; font-weight:700; font-size:0.98rem; cursor:pointer; transition:transform 0.12s ease, background 0.2s ease, border-color 0.2s ease; }
.btn-primary { background:var(--accent); color:#fff; box-shadow:0 6px 16px rgba(38,125,66,0.28); }
.btn-primary:hover { background:#1f6a37; transform:translateY(-1px); }
.btn-ghost { background:#fff; color:var(--ink); border-color:var(--border); }
.btn-ghost:hover { border-color:var(--accent); color:var(--accent); }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:26px; }
.credit a { color:var(--accent); }
@media (max-width:480px){ .btn { flex-basis:100%; } }
@media (prefers-reduced-motion: reduce){ .btn { transition:none; } }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Carte en image</h1>
<p class="sub" data-i18n="sub">Écris un message, choisis un fond, télécharge la carte en image.</p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</header>
<!-- role="img" + aria-label : un canvas est une image. On y mettra le texte courant
pour qu'un lecteur d'écran sache ce qui est dessiné dessus. -->
<canvas id="canvas" role="img" aria-label=""></canvas>
<!-- Le vrai champ accessible, c'est ce textarea (le canvas n'est qu'un aperçu). -->
<textarea id="text" maxlength="120" data-i18n-ph="ph" placeholder="Ton message (ex. Code. Café. Répète.)" aria-label="Message de la carte"></textarea>
<div class="actions">
<button class="btn btn-ghost" id="bg" type="button" data-i18n="bg">Changer le fond</button>
<button class="btn btn-primary" id="dl" type="button" data-i18n="dl">Télécharger en PNG</button>
</div>
<p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>
<script>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES (FR / EN) + les fonds disponibles.
--------------------------------------------------------------------------- */
var UI = {
fr: { title:"Carte en image", sub:"Écris un message, choisis un fond, télécharge la carte en image.",
ph:"Ton message (ex. Code. Café. Répète.)", bg:"Changer le fond", dl:"Télécharger en PNG",
placeholder:"Ton message ici",
credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com' },
en: { title:"Image card", sub:"Write a message, pick a background, download the card as an image.",
ph:"Your message (e.g. Code. Coffee. Repeat.)", bg:"Change background", dl:"Download as PNG",
placeholder:"Your message here",
credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com' }
};
// Fonds CHOISIS, tous assez sombres pour que le texte blanc reste lisible.
var GRADIENTS = [
['#0f1923', '#267d42'], ['#1a1a2e', '#16213e'], ['#2d1b4e', '#5b2a86'],
['#0b3d2e', '#1e6f5c'], ['#3a1c40', '#7d2d52'], ['#102a43', '#334e68']
];
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS + L'ÉTAT.
W et H = la taille LOGIQUE de la carte (en points). La résolution réelle
de l'image sera plus grande sur écran Retina (voir draw()).
--------------------------------------------------------------------------- */
var W = 800, H = 450;
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
var textInput = document.getElementById('text');
var bgBtn = document.getElementById('bg');
var dlBtn = document.getElementById('dl');
var lang = 'fr';
try { lang = localStorage.getItem('card-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
var bgIndex = 0; // quel fond est utilisé
/* ---------------------------------------------------------------------------
3. DÉCOUPER LE TEXTE EN LIGNES.
Le canvas n'a PAS de retour à la ligne automatique : si on écrit une longue
phrase, elle dépasse. On mesure les mots un par un (measureText) et on passe
à la ligne quand on dépasse la largeur disponible.
--------------------------------------------------------------------------- */
function wrapText(text, maxWidth) {
var words = text.split(' ');
var lines = [], line = '';
for (var i = 0; i < words.length; i++) {
var test = line ? line + ' ' + words[i] : words[i];
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line); // la ligne est pleine : on la garde
line = words[i]; // et on commence la suivante
} else {
line = test;
}
}
if (line) lines.push(line);
return lines;
}
/* ---------------------------------------------------------------------------
4. DESSINER la carte (fond + texte + signature).
--------------------------------------------------------------------------- */
function draw() {
// Retina : on dessine à une résolution multipliée par devicePixelRatio,
// sinon l'image téléchargée serait floue sur les écrans haute densité.
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // on continue à raisonner en points logiques
// Le fond : un dégradé en diagonale.
var grad = GRADIENTS[bgIndex];
var g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, grad[0]); g.addColorStop(1, grad[1]);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// Le message, centré et découpé en lignes.
var text = textInput.value.trim() || UI[lang].placeholder;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = "700 40px 'Segoe UI', system-ui, sans-serif";
var lines = wrapText(text, W - 140);
var lineHeight = 52;
var startY = H / 2 - (lines.length - 1) * lineHeight / 2;
lines.forEach(function (l, i) { ctx.fillText(l, W / 2, startY + i * lineHeight); });
// Une petite signature en bas.
ctx.globalAlpha = 0.85;
ctx.font = "600 20px 'Segoe UI', system-ui, sans-serif";
ctx.fillText('</> dev.card', W / 2, H - 44);
ctx.globalAlpha = 1;
// Accessibilité : un canvas est une image MUETTE pour un lecteur d'écran.
// On y attache le texte courant via aria-label, pour qu'il sache ce qui est affiché.
canvas.setAttribute('aria-label', text);
}
/* ---------------------------------------------------------------------------
5. TÉLÉCHARGER l'image — on convertit le canvas en fichier PNG.
--------------------------------------------------------------------------- */
function download() {
canvas.toBlob(function (blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'carte.png';
a.click();
URL.revokeObjectURL(url); // on libère la mémoire une fois le téléchargement lancé
}, 'image/png');
}
/* ---------------------------------------------------------------------------
6. LES ACTIONS + LE DÉMARRAGE.
--------------------------------------------------------------------------- */
textInput.addEventListener('input', draw); // on redessine à chaque frappe
bgBtn.addEventListener('click', function () {
bgIndex = (bgIndex + 1) % GRADIENTS.length; draw(); // fond suivant (et on boucle)
});
dlBtn.addEventListener('click', download);
function setLang(next) {
lang = next;
try { localStorage.setItem('card-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
draw(); // le texte par défaut change avec la langue : on redessine
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
});
setLang(lang); // applique la langue et dessine la première carte
})();
</script>
</body>
</html>
À toi de jouer
Pousse la carte plus loin, en gardant les réflexes du canvas. Quelques pistes pour t'approprier le projet :
- Ajoute un choix de taille de police : un curseur ou trois boutons S/M/L. Pense à recalculer le wrap et le centrage vertical à chaque changement.
- Un bouton format carré (1:1) pour Instagram en plus du format large. Vérifie que
canvas.width,canvas.heightet le DPR sont tous recalculés de façon cohérente. - Permets de déposer une image de fond (cherche « tainted canvas CORS » : une image d'un autre domaine bloque le téléchargement).
Ajoute un sélecteur de taille de police (trois valeurs : 28 px, 40 px, 56 px) qui redessine la carte immédiatement. Tente-le seul d'abord : c'est en produisant sans modèle qu'on sort vraiment du tutoriel. Tu ne déplies le corrigé qu'après avoir essayé.
Tu as réussi si :
- changer la taille redessine la carte sans recharger la page ;
- le wrap se recalcule : avec une grande police, les lignes sont plus courtes ;
- sur un écran Retina, l'image téléchargée reste nette quelle que soit la taille choisie (le DPR est toujours appliqué).
Voir une solution possible
// Ajoute ces trois boutons dans ton HTML :
// <button data-size="28">S</button>
// <button data-size="40">M</button>
// <button data-size="56">L</button>
var fontSize = 40; // taille courante en points logiques
document.querySelectorAll('[data-size]').forEach(function (btn) {
btn.addEventListener('click', function () {
fontSize = parseInt(btn.getAttribute('data-size'), 10);
draw(); // on redessine immédiatement
});
});
// Dans draw(), remplace la ligne ctx.font par :
ctx.font = "700 " + fontSize + "px 'Segoe UI', system-ui, sans-serif";
// Le wrap utilise déjà ctx.measureText(), donc il s'adapte automatiquement
// à la nouvelle taille : le contexte est rééchelonné par setTransform(dpr, ...)
// avant qu'on mesure quoi que ce soit.
Le réflexe clé : ctx.measureText() tient compte de la police courante du contexte. Changer ctx.font avant de mesurer suffit à recalculer le wrap. Et comme ctx.setTransform est appliqué au début de draw(), la netteté Retina ne dépend pas de la taille de police : elle est toujours là.
À chaque ajout : le texte déborde-t-il ? l'image reste-t-elle nette ? l'aperçu est-il annoncé ? Trois questions, une discipline.
The project: turning text into a real image
Seventh project, and a new string to your bow: the canvas. Until now we assembled HTML tags; this time we'll draw, pixel by pixel, in a dedicated area. The goal: you write a message, it appears on a nice gradient card, and a button downloads it as a PNG image — ready to post.
Why is this a good step up? Because drawing is lower-level than stacking HTML: the canvas does nothing "on its own". No line wrapping, no selectable text, nothing for a screen reader. It's exactly the ground where the AI produces a result that's pretty in the screenshot, but shaky as soon as you leave the ideal case. Code fast, review slowly.
A <canvas> is a drawing surface controlled in JavaScript via a "context" (ctx). You give it orders: fill this rectangle, write this text here. At the end, you can export the whole thing as an image file.
- Wrap long text on a canvas: measure each word with
measureTextand build the wrapping loop yourself — the canvas has no concept of line breaks. - Keep a canvas image sharp on Retina screens: multiply the internal resolution by
window.devicePixelRatio, then rescale the context withctx.setTransform. - Make a canvas accessible: drawn text is invisible to screen readers, so you attach
role="img"and anaria-labelthat mirrors the current message.
- a long text wraps instead of overflowing the card frame;
- the downloaded image is sharp on Retina mobile, not pixelated;
- the canvas has a text alternative read by screen readers.
Prompt 1: set the frame
We frame it: a message field, a canvas that draws a card with a gradient, and a button to download the image.
Create a standalone web mini-app, a single HTML file (zero dependencies). A text field; the message shows on a card drawn in a <canvas> with a gradient background and centered white text. A button downloads the card as PNG. Bilingual FR/EN. Simple, readable code.
The AI draws the card. But its drawing code hides three gaps:
canvas.width = 800; canvas.height = 450; // ⚠️ fixed size: blurry image on Retina screens
var ctx = canvas.getContext('2d');
ctx.fillStyle = grad; ctx.fillRect(0, 0, 800, 450);
ctx.fillStyle = '#fff'; ctx.font = '40px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(message, 400, 225); // ⚠️ a single line: a long message overflows
Type a word, it's perfect. Type a sentence, it spills off the sides of the card. And the downloaded image is oddly blurry on a recent phone.
Look at this code before scrolling down. The AI writes the message in a single call: ctx.fillText(message, 400, 225). What do you think happens with a 15-word sentence? Will the canvas wrap the text automatically?
ctx.font = '40px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(message, 400, 225); // the full message, in one shot
Check my prediction
No — the canvas never wraps text on its own. fillText draws everything on one line no matter how long it is: the text simply overflows the card, or gets squashed if you pass a maxWidth argument (but that compresses the text horizontally, it doesn't split it cleanly). You have to measure each word with ctx.measureText(), accumulate words on the current line, and break to a new one as soon as you exceed the available width. It's a small loop that's entirely your responsibility.
My human review: 3 things the AI let slip
The canvas is powerful but primitive: it does exactly what you tell it, nothing more. Everything HTML gave for free (wrapping, accessibility) you must add by hand here. Here are the three points.
1. The canvas doesn't wrap text on its own
fillText writes the text on a single line, period. A slightly long message simply overflows the card. You must split the text yourself: measure the words one by one with ctx.measureText(), and break to a new line as soon as you exceed the available width. It's a small loop, but it's yours to write — the canvas never will.
2. The image is blurry on Retina screens
An 800×450 "pixel" canvas on a high-density screen (phone, recent Mac) is stretched over twice as many real pixels: the result is blurry. The fix: multiply the internal resolution by window.devicePixelRatio, then apply the same scale to the drawing (ctx.setTransform). You still draw in logical "points", but the exported image is sharp.
3. A canvas is mute to a screen reader
Text drawn in a canvas doesn't really exist: it's paint, not text. A screen reader only "sees" an empty image. We fix it in two moves: the real field stays a <textarea> (accessible, it's the source), and we give the canvas role="img" + an aria-label reflecting the current message. The preview becomes understandable for everyone again.
Prompt 2: harden after review
Three gaps, three precisely-requested fixes:
Fix: (1) wrap the text over several lines with measureText so a long message doesn't overflow. (2) Account for window.devicePixelRatio (canvas.width = W * dpr, then ctx.setTransform) for a sharp image on Retina screens. (3) Keep a <textarea> as the accessible field, and set role="img" + an aria-label reflecting the text on the canvas.
// 2. Retina sharpness: we draw at a higher resolution
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr; canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // we then reason in logical points
// 1. line wrapping: we measure word by word
function wrapText(text, maxWidth) {
var words = text.split(' '), lines = [], line = '';
for (var i = 0; i < words.length; i++) {
var test = line ? line + ' ' + words[i] : words[i];
if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = words[i]; }
else line = test;
}
if (line) lines.push(line);
return lines;
}
// 3. accessibility: the drawn text is announced too
canvas.setAttribute('aria-label', text);
The underlying rule, still true here: the tool only does what you tell it. HTML gave you gifts (the browser handled text and accessibility). The canvas makes you responsible for everything. The lower-level you go, the more human review matters.
Host and test
Static file, online in one drop. For testing, keep the usual reflexes, plus the canvas-specific ones:
- Type a long sentence: it must wrap over several lines, never overflow.
- Download the image and open it: on mobile/Retina, the text must be sharp, not pixelated.
- Change the background a few times: the white text stays readable (backgrounds are deliberately dark).
- Tab to the field; check the FR / EN toggle and the console (zero error).
The finished result
The full code (and downloadable)
The entire file, exactly the one running above.
Download the code (.html · 236 lines)
View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte en image (Canvas)</title>
<meta name="description" content="Écris un message, génère une carte et télécharge-la en image. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
CARTE EN IMAGE — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (le canvas, le champ texte, les boutons).
2. <body> : la structure (champ + aperçu canvas + actions).
3. <script> : dessiner sur le canvas et exporter l'image en PNG.
Nouveauté du projet : le <canvas>, une zone où l'on DESSINE en JavaScript
(au lieu d'empiler des balises HTML). Trois pièges classiques à corriger :
le retour à la ligne (le canvas ne le fait pas tout seul), la netteté sur
écran Retina, et l'accessibilité (un canvas est une image muette).
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:560px; }
header { text-align:center; margin-bottom:20px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
/* Le canvas s'affiche en pleine largeur ; sa résolution interne est plus haute (Retina). */
canvas { width:100%; height:auto; display:block; border-radius:16px; box-shadow:0 16px 38px rgba(15,25,35,0.16); }
textarea { width:100%; margin-top:18px; min-height:80px; padding:12px 14px; border:1.5px solid var(--border); border-radius:12px; font:inherit; font-size:1rem; color:var(--ink); resize:vertical; }
textarea:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.actions { display:flex; gap:12px; margin-top:14px; flex-wrap:wrap; }
.btn { flex:1 1 auto; min-width:150px; min-height:50px; border-radius:12px; border:1.5px solid transparent; font:inherit; font-weight:700; font-size:0.98rem; cursor:pointer; transition:transform 0.12s ease, background 0.2s ease, border-color 0.2s ease; }
.btn-primary { background:var(--accent); color:#fff; box-shadow:0 6px 16px rgba(38,125,66,0.28); }
.btn-primary:hover { background:#1f6a37; transform:translateY(-1px); }
.btn-ghost { background:#fff; color:var(--ink); border-color:var(--border); }
.btn-ghost:hover { border-color:var(--accent); color:var(--accent); }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:26px; }
.credit a { color:var(--accent); }
@media (max-width:480px){ .btn { flex-basis:100%; } }
@media (prefers-reduced-motion: reduce){ .btn { transition:none; } }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Carte en image</h1>
<p class="sub" data-i18n="sub">Écris un message, choisis un fond, télécharge la carte en image.</p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</header>
<!-- role="img" + aria-label : un canvas est une image. On y mettra le texte courant
pour qu'un lecteur d'écran sache ce qui est dessiné dessus. -->
<canvas id="canvas" role="img" aria-label=""></canvas>
<!-- Le vrai champ accessible, c'est ce textarea (le canvas n'est qu'un aperçu). -->
<textarea id="text" maxlength="120" data-i18n-ph="ph" placeholder="Ton message (ex. Code. Café. Répète.)" aria-label="Message de la carte"></textarea>
<div class="actions">
<button class="btn btn-ghost" id="bg" type="button" data-i18n="bg">Changer le fond</button>
<button class="btn btn-primary" id="dl" type="button" data-i18n="dl">Télécharger en PNG</button>
</div>
<p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>
<script>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES (FR / EN) + les fonds disponibles.
--------------------------------------------------------------------------- */
var UI = {
fr: { title:"Carte en image", sub:"Écris un message, choisis un fond, télécharge la carte en image.",
ph:"Ton message (ex. Code. Café. Répète.)", bg:"Changer le fond", dl:"Télécharger en PNG",
placeholder:"Ton message ici",
credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com' },
en: { title:"Image card", sub:"Write a message, pick a background, download the card as an image.",
ph:"Your message (e.g. Code. Coffee. Repeat.)", bg:"Change background", dl:"Download as PNG",
placeholder:"Your message here",
credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com' }
};
// Fonds CHOISIS, tous assez sombres pour que le texte blanc reste lisible.
var GRADIENTS = [
['#0f1923', '#267d42'], ['#1a1a2e', '#16213e'], ['#2d1b4e', '#5b2a86'],
['#0b3d2e', '#1e6f5c'], ['#3a1c40', '#7d2d52'], ['#102a43', '#334e68']
];
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS + L'ÉTAT.
W et H = la taille LOGIQUE de la carte (en points). La résolution réelle
de l'image sera plus grande sur écran Retina (voir draw()).
--------------------------------------------------------------------------- */
var W = 800, H = 450;
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
var textInput = document.getElementById('text');
var bgBtn = document.getElementById('bg');
var dlBtn = document.getElementById('dl');
var lang = 'fr';
try { lang = localStorage.getItem('card-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
var bgIndex = 0; // quel fond est utilisé
/* ---------------------------------------------------------------------------
3. DÉCOUPER LE TEXTE EN LIGNES.
Le canvas n'a PAS de retour à la ligne automatique : si on écrit une longue
phrase, elle dépasse. On mesure les mots un par un (measureText) et on passe
à la ligne quand on dépasse la largeur disponible.
--------------------------------------------------------------------------- */
function wrapText(text, maxWidth) {
var words = text.split(' ');
var lines = [], line = '';
for (var i = 0; i < words.length; i++) {
var test = line ? line + ' ' + words[i] : words[i];
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line); // la ligne est pleine : on la garde
line = words[i]; // et on commence la suivante
} else {
line = test;
}
}
if (line) lines.push(line);
return lines;
}
/* ---------------------------------------------------------------------------
4. DESSINER la carte (fond + texte + signature).
--------------------------------------------------------------------------- */
function draw() {
// Retina : on dessine à une résolution multipliée par devicePixelRatio,
// sinon l'image téléchargée serait floue sur les écrans haute densité.
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // on continue à raisonner en points logiques
// Le fond : un dégradé en diagonale.
var grad = GRADIENTS[bgIndex];
var g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, grad[0]); g.addColorStop(1, grad[1]);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// Le message, centré et découpé en lignes.
var text = textInput.value.trim() || UI[lang].placeholder;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = "700 40px 'Segoe UI', system-ui, sans-serif";
var lines = wrapText(text, W - 140);
var lineHeight = 52;
var startY = H / 2 - (lines.length - 1) * lineHeight / 2;
lines.forEach(function (l, i) { ctx.fillText(l, W / 2, startY + i * lineHeight); });
// Une petite signature en bas.
ctx.globalAlpha = 0.85;
ctx.font = "600 20px 'Segoe UI', system-ui, sans-serif";
ctx.fillText('</> dev.card', W / 2, H - 44);
ctx.globalAlpha = 1;
// Accessibilité : un canvas est une image MUETTE pour un lecteur d'écran.
// On y attache le texte courant via aria-label, pour qu'il sache ce qui est affiché.
canvas.setAttribute('aria-label', text);
}
/* ---------------------------------------------------------------------------
5. TÉLÉCHARGER l'image — on convertit le canvas en fichier PNG.
--------------------------------------------------------------------------- */
function download() {
canvas.toBlob(function (blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'carte.png';
a.click();
URL.revokeObjectURL(url); // on libère la mémoire une fois le téléchargement lancé
}, 'image/png');
}
/* ---------------------------------------------------------------------------
6. LES ACTIONS + LE DÉMARRAGE.
--------------------------------------------------------------------------- */
textInput.addEventListener('input', draw); // on redessine à chaque frappe
bgBtn.addEventListener('click', function () {
bgIndex = (bgIndex + 1) % GRADIENTS.length; draw(); // fond suivant (et on boucle)
});
dlBtn.addEventListener('click', download);
function setLang(next) {
lang = next;
try { localStorage.setItem('card-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
draw(); // le texte par défaut change avec la langue : on redessine
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
});
setLang(lang); // applique la langue et dessine la première carte
})();
</script>
</body>
</html>
Your turn
Push the card further, keeping the canvas reflexes. A few open ideas to make the project yours:
- Add a font size choice — a slider or three S/M/L buttons. Remember to recalculate the wrap and vertical centering on each change.
- A square format (1:1) button for Instagram, in addition to the wide format. Make sure
canvas.width,canvas.heightand the DPR are all recalculated consistently. - Let the user drop a background image (look up "tainted canvas CORS": an image from another domain blocks the download).
Add a font size selector (three values: 28 px, 40 px, 56 px) that redraws the card immediately. Try it on your own first — producing without a model is how you truly leave the tutorial behind. Only unfold the solution after you've tried.
You've succeeded if:
- changing the size redraws the card without reloading the page;
- the wrap recalculates — with a large font, fewer words fit per line;
- on a Retina screen, the downloaded image stays sharp regardless of the chosen size (DPR is always applied).
See one possible solution
// Add these three buttons to your HTML:
// <button data-size="28">S</button>
// <button data-size="40">M</button>
// <button data-size="56">L</button>
var fontSize = 40; // current size in logical points
document.querySelectorAll('[data-size]').forEach(function (btn) {
btn.addEventListener('click', function () {
fontSize = parseInt(btn.getAttribute('data-size'), 10);
draw(); // redraw immediately
});
});
// Inside draw(), replace the ctx.font line with:
ctx.font = "700 " + fontSize + "px 'Segoe UI', system-ui, sans-serif";
// wrapText() already uses ctx.measureText(), so it adapts automatically
// to the new size — the context is rescaled by setTransform(dpr, ...)
// before any measurement happens.
The key reflex: ctx.measureText() uses the canvas's current font. Changing ctx.font before measuring is enough to recalculate the wrap. And since ctx.setTransform runs at the top of draw(), Retina sharpness is independent of font size — it's always there.
On every addition: does the text overflow? does the image stay sharp? is the preview announced? Three questions, one discipline.
Tu as demandé la netteté Retina. L'IA te renvoie ceci pour l'init du canvas. Ton rôle de relecteur : accepter tel quel ou rejeter, et dire pourquoi.
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
// on dessine ensuite directement en coordonnées « écran »
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
ctx.font = '40px sans-serif';
ctx.fillText(message, W / 2, H / 2);
canvas.width = W * dpr), mais il manque le ctx.setTransform(dpr, 0, 0, dpr, 0, 0). Sans cette mise à l'échelle, on dessine en coordonnées logiques sur une surface deux fois plus grande : le dégradé et le texte se retrouvent tassés dans le quart en haut à gauche du canvas. C'est le piège classique du devicePixelRatio : agrandir le buffer sans rééchelonner le contexte donne une image nette… mais cadrée n'importe comment.Sans remonter dans la leçon : le <canvas> est plus bas niveau que le HTML. Cite les trois choses qu'il ne fait PAS tout seul et qu'on a dû ajouter à la main pour cette carte.
fillText écrit sur une seule ligne, donc on mesure les mots avec ctx.measureText() et on découpe nous-mêmes ; (2) la netteté Retina — on multiplie la résolution interne par window.devicePixelRatio puis on rééchelonne avec ctx.setTransform ; (3) l'accessibilité — le texte dessiné est muet, donc le champ reste un vrai <textarea> et le canvas reçoit role="img" + un aria-label. La règle qui les relie : l'outil ne fait que ce qu'on lui dit.Tu sais maintenant dessiner une carte en image partageable directement dans le navigateur. Au projet suivant, tu montes ton premier outil d'organisation : un tableau Kanban où tu glisses tes cartes d'une colonne à l'autre.
Leçon 8 : Tableau Kanban →