Wrapping Text

Wrapping Text to a Maximum Width

fillText()'s fourth argument, maxWidth, does not wrap: it squeezes the glyphs until the text fits. To wrap, measure word by word and break before the word that would overflow (kept in demos/ch02/wrap.js).

wrapLines() on two BookNest titles, against the maxWidth squeezeHTML
<canvas id="c" style="width:600px; height:100px"></canvas><script src="hidpi.js"></script>
<script>
  function wrapLines(ctx, text, maxWidth) {
    const lines = [];  let line = '';
    for (const word of text.split(/\s+/)) {
      const test = line ? line + ' ' + word : word;
      if (line && ctx.measureText(test).width > maxWidth) { lines.push(line);  line = word; }
      else line = test;
    }
    return lines.concat(line ? [line] : []);
  }
  const ctx = hidpi(c);  ctx.font = 'bold 18px Georgia';
  [['Small Steps to Big Summits', '#3f7d3a', 10], ["The Clockmaker's Paradox", '#b5452f', 160]]
    .forEach(([title, color, x]) => {
      ctx.fillStyle = color;  ctx.fillRect(x, 4, 130, 92);  ctx.fillStyle = '#fff3e0';
      wrapLines(ctx, title, 110).forEach((line, k) => ctx.fillText(line, x + 10, 30 + k * 22));
    });
  ctx.fillStyle = '#5b3f99';  ctx.fillRect(320, 4, 130, 92);  ctx.fillStyle = '#fff3e0';
  ctx.fillText('Patterns of the Deep Web', 330, 30, 110);             // maxWidth squeezes
</script>
Browser output of Listing 2.14
Browser output of 14

A word wider than the box still overflows, as Clockmaker's does; the cover designer (Cover Designer) shrinks the font until the longest word fits. Splitting on spaces fails for Chinese, Japanese and Thai, which do not separate words; Intl.Segmenter with granularity: 'word' finds their break points.

Word wrapping with measureText versus fillText's squeezing maxWidthHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; }
  canvas { width: 100%; max-width: 600px; height: 160px; display: block; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c"></canvas>
<label>Cover width <input type="range" id="w" min="90" max="180" value="130"></label>
<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;
  }
  // Break before the word that would overflow maxWidth
  function wrapLines(ctx, text, maxWidth) {
    const lines = [];  let line = '';
    for (const word of text.split(/\s+/)) {
      const test = line ? line + ' ' + word : word;
      if (line && ctx.measureText(test).width > maxWidth) { lines.push(line);  line = word; }
      else line = test;
    }
    return lines.concat(line ? [line] : []);
  }
  const ctx = hidpi(document.getElementById('c'));

  function draw(w) {
    ctx.clearRect(0, 0, 600, 160);
    ctx.font = 'bold 18px Georgia';
    [['Small Steps to Big Summits', '#3f7d3a', 10], ["The Clockmaker's Paradox", '#b5452f', 20 + w]]
      .forEach(([title, color, x]) => {
        ctx.fillStyle = color;  ctx.fillRect(x, 4, w, 130);
        ctx.fillStyle = '#fff3e0';
        wrapLines(ctx, title, w - 20).forEach((line, k) => ctx.fillText(line, x + 10, 30 + k * 22));
      });
    // maxWidth (4th argument) squeezes the glyphs instead of wrapping
    const x = 30 + 2 * w;
    ctx.fillStyle = '#5b3f99';  ctx.fillRect(x, 4, w, 130);  ctx.fillStyle = '#fff3e0';
    ctx.fillText('Patterns of the Deep Web', x + 10, 30, w - 20);
    ctx.font = '11px sans-serif';  ctx.fillStyle = '#263238';
    ctx.fillText('wrapLines()', 10, 150);  ctx.fillText('long word overflows', 20 + w, 150);
    ctx.fillText('maxWidth squeezes', x, 150);
  }
  const slider = document.getElementById('w');
  slider.addEventListener('input', () => draw(+slider.value));
  draw(130);
</script>