Cover Designer

Building BookNest's Cover Designer

The cover designer combines the chapter's pieces: a rounded cover with a gradient from the book's color to ink, a spine strip, the title wrapped and shrunk until it fits (the fix for Clockmaker's in Wrapping Text), the author and the mark of Path2D and Rounded Rectangles, each cover drawn in its own translated space.

BookNest's cover designer: six covers with fitted, wrapped titlesHTML
<canvas id="c" style="width:600px; height:156px"></canvas>
<script src="hidpi.js"></script><script src="wrap.js"></script>
<script src="booknest-mark.js"></script><script src="booknest-data.js"></script>
<script>
  const ctx = hidpi(c), W = 88, H = 132;
  function fitTitle(title, maxWidth, maxLines) {           // largest size whose lines all fit
    for (let size = 18; ; size--) {
      ctx.font = `bold ${size}px Georgia`;
      const lines = wrapLines(ctx, title, maxWidth);
      if (size === 9 || lines.length <= maxLines &&
          lines.every(line => ctx.measureText(line).width <= maxWidth)) return { size, lines };
    }
  }
  function drawCover(book, x, y, selected) {
    ctx.save();  ctx.translate(x, y);
    const sheen = ctx.createLinearGradient(0, 0, W, H);  sheen.addColorStop(0.35, book.color);
    sheen.addColorStop(1, '#263238');  ctx.fillStyle = sheen;
    ctx.beginPath();  ctx.roundRect(0, 0, W, H, [2, 6, 6, 2]);  ctx.fill();
    ctx.fillStyle = 'rgb(0 0 0 / 0.25)';  ctx.fillRect(0, 0, 7, H);             // spine
    const { size, lines } = fitTitle(book.title, W - 20, 4);
    ctx.fillStyle = '#fff3e0';
    lines.forEach((line, k) => ctx.fillText(line, 12, 8 + (k + 1) * size * 1.15));
    ctx.font = 'italic 9px Georgia';  ctx.fillText(book.author, 12, H - 10);
    drawMark(ctx, W - 30, H - 50, 0.12);
    if (selected) { ctx.setLineDash([4, 3]);  ctx.strokeRect(-4, -4, W + 8, H + 8); }
    ctx.restore();
  }
  BOOKS.forEach((book, i) => drawCover(book, 8 + i * 98, 12, i === 4));
  __done = true;
</script>
Browser output of Listing 2.24
Browser output of 24

fitTitle() steps down from 18 pixels until at most four lines fit, so long titles shrink and short ones stay large. demos/ch02/cover-designer.html adds dragging with setPointerCapture(), a fallback button per cover and a PNG export through toBlob().

BookNest's cover designer: gradient covers, fitted titles and a draggable selectionHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; }
  canvas { width: 600px; max-width: 100%; height: 200px; display: block; background: #f5f1e8; border: 1px solid #ddd; touch-action: none; }
</style>
<canvas id="c"></canvas>
<p>Click a cover to select it, drag to move it.</p>
<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;
  }
  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 BOOKS = [
    { title: 'The Quiet Harbor', author: 'Elena Marsh', color: '#1f5f8b' },
    { title: 'Patterns of the Deep Web', author: 'Ravi Menon', color: '#5b3f99' },
    { title: 'Salt and Saffron', author: 'Leila Haddad', color: '#e09a10' },
    { title: 'Small Steps to Big Summits', author: 'Tom Okafor', color: '#3f7d3a' },
    { title: "The Clockmaker's Paradox", author: 'Iris Vale', color: '#b5452f' },
    { title: 'Gardens in Glass', author: 'June Park', color: '#2a9d8f' }
  ];
  const c = document.getElementById('c');
  const ctx = hidpi(c), W = 88, H = 132;
  const nest = new Path2D('M36 118 Q120 142 204 118 Q196 182 120 184 Q44 182 36 118 Z');
  const items = BOOKS.map((book, i) => ({ book, x: 8 + i * 98, y: 34 }));
  let selected = items[4], drag = null;

  // Largest font size (18 down to 9) at which the title fits in maxLines lines
  function fitTitle(title, maxWidth, maxLines) {
    for (let size = 18; ; size--) {
      ctx.font = `bold ${size}px Georgia`;
      const lines = wrapLines(ctx, title, maxWidth);
      if (size === 9 || lines.length <= maxLines &&
          lines.every(line => ctx.measureText(line).width <= maxWidth)) return { size, lines };
    }
  }
  function drawCover({ book, x, y }, isSelected) {
    ctx.save();  ctx.translate(x, y);                 // each cover in its own space
    const sheen = ctx.createLinearGradient(0, 0, W, H);
    sheen.addColorStop(0.35, book.color);  sheen.addColorStop(1, '#263238');
    ctx.fillStyle = sheen;  ctx.beginPath();  ctx.roundRect(0, 0, W, H, [2, 6, 6, 2]);  ctx.fill();
    ctx.fillStyle = 'rgb(0 0 0 / 0.25)';  ctx.fillRect(0, 0, 7, H);           // spine
    const { size, lines } = fitTitle(book.title, W - 20, 4);
    ctx.fillStyle = '#fff3e0';
    lines.forEach((line, k) => ctx.fillText(line, 12, 8 + (k + 1) * size * 1.15));
    ctx.font = 'italic 9px Georgia';  ctx.fillText(book.author, 12, H - 10);
    ctx.save();  ctx.translate(W - 30, H - 50);  ctx.scale(0.1, 0.1);           // the mark
    ctx.fillStyle = 'rgb(255 243 224 / 0.8)';  ctx.fill(nest);  ctx.restore();
    if (isSelected) {
      ctx.setLineDash([4, 3]);  ctx.strokeStyle = '#263238';  ctx.strokeRect(-4, -4, W + 8, H + 8);
    }
    ctx.restore();
  }
  function draw() {
    ctx.clearRect(0, 0, 600, 200);
    items.forEach(item => drawCover(item, item === selected));
  }

  const local = e => { const b = c.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };
  c.addEventListener('pointerdown', e => {
    const [px, py] = local(e);
    const hit = items.findLast(it => px >= it.x && px <= it.x + W && py >= it.y && py <= it.y + H);
    selected = hit || null;
    if (hit) {
      items.push(items.splice(items.indexOf(hit), 1)[0]);        // bring to front
      drag = { dx: px - hit.x, dy: py - hit.y };
      c.setPointerCapture(e.pointerId);                          // keep events during the drag
    }
    draw();
  });
  c.addEventListener('pointermove', e => {
    if (!drag || !selected) return;
    const [px, py] = local(e);
    selected.x = px - drag.dx;  selected.y = py - drag.dy;
    draw();
  });
  c.addEventListener('pointerup', () => drag = null);
  draw();
</script>