ctx.font takes the CSS font shorthand and ignores values lacking a size or family. textAlign places text against x and textBaseline (alphabetic, top, middle, bottom and others) against y. fillText() and strokeText() draw it; measureText() returns a TextMetrics whose actualBoundingBox* values give the ink's extent.
<canvas id="c" style="width:600px; height:82px"></canvas><script src="hidpi.js"></script>
<script>
const ctx = hidpi(c), title = 'Salt and Saffron';
ctx.font = 'bold 36px Georgia';
const m = ctx.measureText(title);
ctx.strokeStyle = '#b5452f'; ctx.strokeRect(10 - m.actualBoundingBoxLeft,
52 - m.actualBoundingBoxAscent, m.actualBoundingBoxLeft + m.actualBoundingBoxRight,
m.actualBoundingBoxAscent + m.actualBoundingBoxDescent); // the ink box
ctx.fillStyle = '#e09a10'; ctx.fillText(title, 10, 52);
ctx.fillRect(0, 52, 330, 1); // the baseline
ctx.lineWidth = 8; ctx.lineJoin = 'round'; ctx.strokeStyle = '#263238';
ctx.strokeText('SOLD OUT', 360, 52); // outline first...
ctx.fillStyle = '#fff3e0'; ctx.fillText('SOLD OUT', 360, 52); // ...then the fill
ctx.font = '13px sans-serif'; ctx.fillStyle = '#263238';
ctx.fillText(`width ${m.width.toFixed(1)}, ascent ${m.actualBoundingBoxAscent}`, 10, 76);
</script>
Stroking first and filling on top keeps the letters intact, like SVG's paint-order (<text> and <tspan>). Load a web font before drawing, or the fallback is painted for good: await document.fonts.load('36px Lobster').
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #fafaf7; }
canvas { width: 100%; max-width: 600px; height: 190px; display: block; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c"></canvas>
<script>
function hidpi(c, r = devicePixelRatio) {
c.width = c.clientWidth * r; c.height = c.clientHeight * r;
const ctx = c.getContext('2d'); ctx.scale(r, r); return ctx;
}
const ctx = hidpi(document.getElementById('c')), title = 'Salt and Saffron';
// 1. Measure the title and outline its actual ink box
ctx.font = 'bold 36px Georgia';
const m = ctx.measureText(title);
ctx.strokeStyle = '#b5452f';
ctx.strokeRect(10 - m.actualBoundingBoxLeft, 52 - m.actualBoundingBoxAscent,
m.actualBoundingBoxLeft + m.actualBoundingBoxRight,
m.actualBoundingBoxAscent + m.actualBoundingBoxDescent);
ctx.fillStyle = '#e09a10'; ctx.fillText(title, 10, 52);
ctx.fillRect(0, 52, 330, 1); // the baseline
// 2. Outline first, then fill on top, so the stroke doesn't eat the letters
ctx.lineWidth = 8; ctx.lineJoin = 'round'; ctx.strokeStyle = '#263238';
ctx.strokeText('SOLD OUT', 370, 52);
ctx.fillStyle = '#fff3e0'; ctx.fillText('SOLD OUT', 370, 52);
ctx.font = '13px sans-serif'; ctx.fillStyle = '#263238';
ctx.fillText(`width ${m.width.toFixed(1)}px, ascent ${m.actualBoundingBoxAscent.toFixed(1)}px, ` +
`descent ${m.actualBoundingBoxDescent.toFixed(1)}px`, 10, 80);
// 3. textBaseline positions the text against the y coordinate (red line at y = 140)
ctx.fillStyle = '#b5452f'; ctx.fillRect(0, 140, 600, 1);
ctx.font = '18px Georgia'; ctx.fillStyle = '#1f5f8b';
['top', 'hanging', 'middle', 'alphabetic', 'ideographic', 'bottom'].forEach((base, i) => {
ctx.textBaseline = base;
ctx.fillText('Abg', 14 + i * 98, 140);
ctx.save(); ctx.textBaseline = 'alphabetic'; ctx.font = '11px sans-serif'; ctx.fillStyle = '#555';
ctx.fillText(base, 14 + i * 98, 180); ctx.restore();
});
</script>