在 HTML Canvas 中打字时使文本适合圆形(带缩放)

问题描述 投票:0回答:1

我试图在打字时让文本适合一个圆圈,如下所示:

我尝试按照Mike Bostock的将文本调整为圆圈教程,但到目前为止失败了,这是我可怜的尝试:

import React, { useEffect, useRef, useState } from "react";

export const TwoPI = 2 * Math.PI;

export function setupGridWidthHeightAndScale(
  width: number,
  height: number,
  canvas: HTMLCanvasElement
) {
  canvas.style.width = width + "px";
  canvas.style.height = height + "px";

  // Otherwise we get blurry lines
  // Referenece: [Stack Overflow - Canvas drawings, like lines, are blurry](https://stackoverflow.com/a/59143499/4756173)
  const scale = window.devicePixelRatio;

  canvas.width = width * scale;
  canvas.height = height * scale;

  const canvasCtx = canvas.getContext("2d")!;

  canvasCtx.scale(scale, scale);
}


type CanvasProps = {
  width: number;
  height: number;
};

export function TextInCircle({
  width,
  height,
}: CanvasProps) {
  const [text, setText] = useState("");

  const canvasRef = useRef<HTMLCanvasElement>(null);

  function getContext() {
    const canvas = canvasRef.current!;
    return canvas.getContext("2d")!;
  }

  useEffect(() => {
    const canvas = canvasRef.current!;
    setupGridWidthHeightAndScale(width, height, canvas);

    const ctx = getContext();

    // Background
    ctx.fillStyle = "black";
    ctx.fillRect(0, 0, width, height);

    // Circle
    ctx.beginPath();
    ctx.arc(width / 2, height / 2, 100, 0, TwoPI);
    ctx.closePath();

    // Fill the Circle
    ctx.fillStyle = "white";
    ctx.fill();
  }, [width, height]);

  function handleChange(
    e: React.ChangeEvent<HTMLInputElement>
  ) {
    const newText = e.target.value;
    setText(newText);

    // Split Words
    const words = text.split(/\s+/g); // To hyphenate: /\s+|(?<=-)/
    if (!words[words.length - 1]) words.pop();
    if (!words[0]) words.shift();

    // Get Width
    const lineHeight = 12;
    const targetWidth = Math.sqrt(
      measureWidth(text.trim()) * lineHeight
    );

    // Split Lines accordingly
    const lines = splitLines(targetWidth, words);

    // Get radius so we can scale
    const radius = getRadius(lines, lineHeight);

    // Draw Text
    const ctx = getContext();

    ctx.textAlign = "center";
    ctx.fillStyle = "black";
    for (const [i, l] of lines.entries()) {
      // I'm totally lost as to how to proceed here...
      ctx.fillText(
        l.text,
        width / 2 - l.width / 2,
        height / 2 + i * lineHeight
      );
    }
  }

  function measureWidth(s: string) {
    const ctx = getContext();
    return ctx.measureText(s).width;
  }

  function splitLines(
    targetWidth: number,
    words: string[]
  ) {
    let line;
    let lineWidth0 = Infinity;
    const lines = [];

    for (let i = 0, n = words.length; i < n; ++i) {
      let lineText1 =
        (line ? line.text + " " : "") + words[i];

      let lineWidth1 = measureWidth(lineText1);

      if ((lineWidth0 + lineWidth1) / 2 < targetWidth) {
        line!.width = lineWidth0 = lineWidth1;
        line!.text = lineText1;
      } else {
        lineWidth0 = measureWidth(words[i]);
        line = { width: lineWidth0, text: words[i] };
        lines.push(line);
      }
    }
    return lines;
  }

  function getRadius(
    lines: { width: number; text: string }[],
    lineHeight: number
  ) {
    let radius = 0;

    for (let i = 0, n = lines.length; i < n; ++i) {
      const dy =
        (Math.abs(i - n / 2 + 0.5) + 0.5) * lineHeight;

      const dx = lines[i].width / 2;

      radius = Math.max(
        radius,
        Math.sqrt(dx ** 2 + dy ** 2)
      );
    }

    return radius;
  }

  return (
    <>
      <input type="text" onChange={handleChange} />

      <canvas ref={canvasRef}></canvas>
    </>
  );
}

我也尝试遵循 @markE 2013 年的回答。但文本似乎并没有随着圆的半径而缩放,在该示例中是相反的,据我所知,半径被缩放以适合文本。而且,由于某种原因,更改示例文本会产生

text is undefined
错误,我不知道为什么。

import React, { useEffect, useRef, useState } from "react";

export const TwoPI = 2 * Math.PI;

export function setupGridWidthHeightAndScale(
  width: number,
  height: number,
  canvas: HTMLCanvasElement
) {
  canvas.style.width = width + "px";
  canvas.style.height = height + "px";

  // Otherwise we get blurry lines
  // Referenece: [Stack Overflow - Canvas drawings, like lines, are blurry](https://stackoverflow.com/a/59143499/4756173)
  const scale = window.devicePixelRatio;

  canvas.width = width * scale;
  canvas.height = height * scale;

  const canvasCtx = canvas.getContext("2d")!;

  canvasCtx.scale(scale, scale);
}

type CanvasProps = {
  width: number;
  height: number;
};

export function TextInCircle({
  width,
  height,
}: CanvasProps) {
  const [typedText, setTypedText] = useState("");

  const canvasRef = useRef<HTMLCanvasElement>(null);

  function getContext() {
    const canvas = canvasRef.current!;
    return canvas.getContext("2d")!;
  }

  useEffect(() => {
    const canvas = canvasRef.current!;
    setupGridWidthHeightAndScale(width, height, canvas);
  }, [width, height]);

  const textHeight = 15;
  const lineHeight = textHeight + 5;
  const cx = 150;
  const cy = 150;
  const r = 100;

  function handleChange(
    e: React.ChangeEvent<HTMLInputElement>
  ) {
    const ctx = getContext();

    const text = e.target.value; // This gives out an error
    // "'Twas the night before Christmas, when all through the house,  Not a creature was stirring, not even a mouse.  And so begins the story of the day of";

    const lines = initLines();
    wrapText(text, lines);

    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2, false);
    ctx.closePath();
    ctx.strokeStyle = "skyblue";
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // pre-calculate width of each horizontal chord of the circle
  // This is the max width allowed for text

  function initLines() {
    const lines: any[] = [];

    for (let y = r * 0.9; y > -r; y -= lineHeight) {
      let h = Math.abs(r - y);

      if (y - lineHeight < 0) {
        h += 20;
      }

      let length = 2 * Math.sqrt(h * (2 * r - h));

      if (length && length > 10) {
        lines.push({
          y: y,
          maxLength: length,
        });
      }
    }

    return lines;
  }

  // draw text on each line of the circle

  function wrapText(text: string, lines: any[]) {
    const ctx = getContext();

    let i = 0;
    let words = text.split(" ");

    while (i < lines.length && words.length > 0) {
      let line = lines[i++];

      let lineData = calcAllowableWords(
        line.maxLength,
        words
      );

      ctx.fillText(
        lineData!.text,
        cx - lineData!.width / 2,
        cy - line.y + textHeight
      );

      words.splice(0, lineData!.count);
    }
  }

  // calculate how many words will fit on a line

  function calcAllowableWords(
    maxWidth: number,
    words: any[]
  ) {
    const ctx = getContext();

    let wordCount = 0;
    let testLine = "";
    let spacer = "";
    let fittedWidth = 0;
    let fittedText = "";

    const font = "12pt verdana";
    ctx.font = font;

    for (let i = 0; i < words.length; i++) {
      testLine += spacer + words[i];
      spacer = " ";

      let width = ctx.measureText(testLine).width;

      if (width > maxWidth) {
        return {
          count: i,
          width: fittedWidth,
          text: fittedText,
        };
      }

      fittedWidth = width;
      fittedText = testLine;
    }
  }

  return (
    <>
      <input type="text" onChange={handleChange} />

      <canvas ref={canvasRef}></canvas>
    </>
  );
}
javascript html reactjs svg html5-canvas
1个回答
1
投票

这是我的尝试:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Text in circle</title>
  <style>
    body {
      font-family: "News Cycle", Verdana, Arial, sans-serif;
    }
  </style>
</head>

<body>
  <div>
    <label for="text">Text input</label>
    <textarea id="text" type="text" oninput="onChange('text')">Text meant
to fit within
circle</textarea>
  </div>
  <div>
    <label for="radius">Circle Radius</label>
    <input id="radius" type="range" value="150" max="500" oninput="onChange('radius', Number)" />
  </div>
  <div>
    <label for="font">Font Size</label>
    <input id="font" type="range" value="60" max="500" oninput="onChange('font', Number)" />
  </div>
  <div>
    <label for="lineHeight">Line Height in 1/100</label>
    <input id="lineHeight" type="range" value="150" min="100" max="1000" oninput="onChange('lineHeight', Number)" />
  </div>
  <div>
    <label for="canvas_height">Canvas height</label>
    <input id="canvas_height" type="range" value="500" max="5000" oninput="onChange('canvas_height', Number)" />
  </div>
  <div>
    <label for="canvas_width">Canvas width</label>
    <input id="canvas_width" type="range" value="600" max="5000" oninput="onChange('canvas_width', Number)" />
  </div>
  <div>
    <canvas id="canvas"></canvas>
  </div>
  <script>
    const values = {
      radius: Number(document.getElementById('radius').value),
      font: Number(document.getElementById('font').value),
      canvas_height: Number(document.getElementById('canvas_height').value),
      canvas_width: Number(document.getElementById('canvas_width').value),
      text: document.getElementById('text').value,
      lineHeight: Number(document.getElementById('lineHeight').value)
    };
    const canvas = document.getElementById('canvas');
    /** @type {CanvasRenderingContext2D} */
    const ctx = canvas.getContext('2d');

    redraw();

    function onChange(id, transform = i => i) {
      setValue(id, transform);
      redraw();
    }

    function setValue(id, transform = i => i) {
      values[id] = transform(document.getElementById(id).value);
    }

    function fitTextIntoCircle(fontSize = values.font) {
      if (fontSize < 6) {
        return;
      }
      ctx.font = `${fontSize}px serif`;
      const circleObj = {
        x: values.radius,
        y: values.radius,
        r: values.radius
      };
      const measurement = ctx.measureText(values.text)
      const height = measurement.actualBoundingBoxAscent + measurement.actualBoundingBoxDescent;
      let queue = [];
      let isFail = false;

      values.text.split('\n').forEach((line) => {
        const measurement = ctx.measureText(line)
        const box = {
          width: measurement.actualBoundingBoxRight + measurement.actualBoundingBoxLeft,
          height
        };
        const x = values.radius - (box.width) / 2;
        let y;
        y = Math.max(
          getHighestSuitableForTextPoint(box.width),
          (queue[queue.length - 1] ? .y + values.lineHeight / 100 * queue[queue.length - 1] ? .box.height) || 0
        );

        const topLeftIsWithinCircle = pixelBelongsToCircle(circleObj, {
          x,
          y
        });
        const bottomLeftIsWithinCircle = pixelBelongsToCircle(circleObj, {
          x,
          y: y + box.height
        });

        if (topLeftIsWithinCircle && bottomLeftIsWithinCircle) {
          queue.push({
            x,
            y,
            box,
            line
          })
        } else {
          isFail = true;
        }
      })
      if (isFail) {
        clear();
        return fitTextIntoCircle(fontSize - 1);
      }
      queue.forEach(({
        x,
        y,
        box,
        line
      }) => {
        //ctx.fillStyle = "grey";
        //ctx.fillRect(x, y, box.width, box.height)
        ctx.fillStyle = "red";
        ctx.fillText(`${line}`, x, y + box.height);

      })

    }

    function drawCircle() {
      ctx.beginPath();
      ctx.arc(values.radius, values.radius, values.radius, 0, Math.PI * 2);
      ctx.closePath();
      ctx.fillStyle = "black";
      ctx.fill();
    }

    function drawRect() {
      canvas.width = values.canvas_width;
      canvas.height = values.canvas_height;
      ctx.fillStyle = "white";
      ctx.fillRect(0, 0, values.canvas_width, values.canvas_height);
      ctx.fill();
    }

    /**
     * @param {{x: number, y:number, r: number }} circle
     * @param {{ x: number, y: number  }} pixel
     */
    function pixelBelongsToCircle(circle, pixel) {
      // (x - a)^2 + (y - b)^2 <= r^2
      return (pixel.x - circle.x) ** 2 + (pixel.y - circle.y) ** 2 <= circle.r ** 2;
    }

    function getHighestSuitableForTextPoint(width) {
      const r = values.radius;
      return 1 + r - (r ** 2 - (width / 2) ** 2) ** 0.5;
    }

    function clear() {
      drawRect();
      drawCircle();
    }

    function redraw() {
      clear();
      fitTextIntoCircle()
    }
  </script>
</body>

</html>

我使用了一些几何图形来计算每行文本的边界框。 Canvas 的measureText() 在处理文本中的前导/尾随空格时有些出乎意料,因此需要牢记或在更好地理解后进行改进。

代码按照设置的字体大小逐行绘制,除非有一条线不适合圆圈。在这种情况下,队列将被清理,并以减小的字体大小重试代码。这是线性下降,但如果找到停止条件,则可以使用二分搜索以获得更好的性能。

getHighestSuitableForTextPoint() 中的

+1 像素可防止舍入错误,因为浮点精度对于此应用程序来说不是最佳选择,因此文本行会闪烁。

© www.soinside.com 2019 - 2024. All rights reserved.