如何将文本调整到 html canvas 上的精确宽度?

How to fit text to a precise width on html canvas?

如何使单行文本字符串适合 html5 canvas 上的精确宽度?到目前为止,我尝试的是以初始字体大小编写文本,用 measureText(my_text).width 测量文本的宽度,然后根据我想要的文本宽度与实际文本宽度之间的比率计算新的字体大小。它给出的结果大致正确,但根据文本的不同,边缘有一些白色 space。

下面是一些示例代码:

// Draw "guard rails" with 200px space in between
c.fillStyle = "lightgrey";
c.fillRect(90, 0, 10, 200);
c.fillRect(300, 0, 10, 200);

// Measure how wide the text would be with 100px font
var my_text = "AA";
var initial_font_size = 100;
c.font = initial_font_size + "px Arial";
var initial_text_width = c.measureText(my_text).width;

// Calculate the font size to exactly fit the desired width of 200px
var desired_text_width = 200; 
new_font_size = initial_font_size * desired_text_width / initial_text_width;

// Draw the text with the new font size
c.font = new_font_size + "px Arial";
c.fillStyle = "black";
c.textBaseline = "top";
c.fillText(my_text, 100, 0, 500);

结果对于某些字符串来说是完美的,例如 "AA":

但对于其他字符串,如 "BB",边缘有一个间隙,您可以看到文本没有到达“护栏”:

我怎样才能使文本始终到达边缘?

你遇到的问题是TextMetrics.width表示文本的“前进宽度”。
This answer explains pretty well what it is, and links to good resources

The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.

这里你要的是bounding-box宽度,要得到这个,你需要计算TextMetric.actualBoundingBoxLeft + TextMetric.actualBoundingBoxRight.
的总和 另请注意,在呈现文本时,您必须考虑边界框的 actualBoundingBoxLeft 偏移量以使其正确适合。

不幸的是,所有浏览器都不支持扩展的 TextMetrics 对象,实际上只有 Chrome 真正支持,因为 Safari 错误地 returns advance width 用于边界框值。对于其他浏览器,我们运气不好,不得不依赖丑陋的 getImageData hacks。

const supportExtendedMetrics = 'actualBoundingBoxRight' in TextMetrics.prototype;
if( !supportExtendedMetrics ) {
  console.warn( "Your browser doesn't support extended properties of TextMetrics." );
}

const canvas = document.getElementById('canvas');
const c = canvas.getContext('2d');
c.textBaseline = "top";

const input = document.getElementById('inp');
input.oninput = (e) => {

  c.clearRect(0,0, canvas.width, canvas.height);
  // Draw "guard rails" with 200px space in between
  c.fillStyle = "lightgrey";
  c.fillRect(90, 0, 10, 200);
  c.fillRect(300, 0, 10, 200);

  c.fillStyle = "black";
  fillFittedText(c, inp.value, 100, 0, 200) ;

};
input.oninput();

function fillFittedText( ctx, text = "", x = 0, y = 0, target_width = ctx.canvas.width, font_family = "Arial" ) {
  let font_size = 1;
  const updateFont = () => {
    ctx.font = font_size + "px " + font_family;
  };
  updateFont();
  let width = getBBOxWidth(text);
  // first pass width increment = 1
  while( width && width <= target_width ) {
    font_size++;
    updateFont();
    width = getBBOxWidth(text);
  }
  // second pass, the other way around, with increment = -0.1
  while( width && width > target_width ) {
    font_size -= 0.1;
    updateFont();
    width = getBBOxWidth(text);
  }
  // revert to last valid step
  font_size += 0.1;
  updateFont();
  
  // we need to measure where our bounding box actually starts
  const offset_left = c.measureText(text).actualBoundingBoxLeft || 0;
  ctx.fillText(text, x + offset_left, y);

  function getBBOxWidth(text) {
    const measure = ctx.measureText(text);
    return supportExtendedMetrics ? 
      (measure.actualBoundingBoxLeft + measure.actualBoundingBoxRight) :
      measure.width;
  }

}
<input type="text" id="inp" value="BB">
<canvas id="canvas" width="500"></canvas>

测量文本宽度

测量文本在很多层面上都存在问题。

完整的和实验性的 textMetric 已经定义多年,但仅在 1 个主流浏览器 (Safari) 上可用,隐藏在标志后面 (Chrome),由于错误 ( Firefox),状态未知(Edge,IE)。

仅使用 width

充其量您可以使用 return 由 ctx.measureText 编辑的对象的 width 属性 来估计宽度。此宽度大于或等于实际像素宽度(最左到右)。注意网络字体必须完全加载或者宽度可能是占位符字体的宽度。

蛮力

不幸的是,唯一能够可靠工作的方法是一种蛮力技术,它将字体呈现为临时/或工作 canvas 并通过查询像素计算范围。

这适用于所有支持 canvas 的浏览器。

不适用于实时动画和应用程序。

下面的函数

  • 将return一个具有以下属性的对象

    • width 宽度 canvas 文本像素
    • left 距第一个像素左侧的距离 canvas 像素
    • right 从左侧到最后检测到的像素的距离 canvas 像素
    • rightOffset 距测量文本宽度和检测到的右边缘 canvas 像素的距离
    • measuredWidth return 由 ctx.measureText
    • 编辑的测量宽度
    • baseSize 以像素为单位的字体大小
    • font用于测量文字的字体
  • 如果宽度为零或字符串不包含可见文本,它将 return undefined

然后您可以使用固定大小的字体和 2D 变换来缩放文本以适应所需的宽度。这将适用于非常小的字体,从而以更小的尺寸呈现更高质量的字体。

准确性取决于所测量字体的大小。该函数使用 120px 的固定字体大小,您可以通过传递 属性

来设置基本大小

该函数可以使用部分文本(快捷方式)来减少 RAM 和处理开销。 属性 rightOffset 是从右 ctx.measureText 边缘到第一个包含内容的像素的距离(以像素为单位)。

因此您可以测量文本 "CB" 并使用该测量来准确对齐以 "C" 开头并以 "B"

结尾的任何文本

使用快捷文本的示例

    const txtSize = measureText({font: "arial", text: "BB"});
    ctx.font = txtSize.font;
    const width = ctx.measureText("BabcdefghB").width;
    const actualWidth = width - txtSize.left - txtSize.rightOffset;
    const scale = canvas.width / actualWidth;
    ctx.setTransform(scale, 0, 0, scale,  -txtSize.left * scale, 0);
    ctx.fillText("BabcdefghB",0,0);

measureText 函数

const measureText = (() => {
    var data, w, size =  120; // for higher accuracy increase this size in pixels.
    const isColumnEmpty = x => {
       var idx = x, h = size * 2;
       while (h--) {
           if (data[idx]) { return false }
           idx += can.width;
       }
       return true;
    }
    const can = document.createElement("canvas");
    const ctx = can.getContext("2d");
    return ({text, font, baseSize = size}) => {   
        size = baseSize;
        can.height = size * 2;
        font = size + "px "+ font;          
        if (text.trim() === "") { return }
        ctx.font = font;
        can.width = (w = ctx.measureText(text).width) + 8;
        ctx.font = font;
        ctx.textBaseline = "middle";
        ctx.textAlign = "left";
        ctx.fillText(text, 0, size);
        data = new Uint32Array(ctx.getImageData(0, 0, can.width, can.height).data.buffer);
        var left, right;
        var lIdx = 0, rIdx = can.width - 1;
        while(lIdx < rIdx) {
            if (left === undefined && !isColumnEmpty(lIdx)) { left = lIdx }
            if (right === undefined && !isColumnEmpty(rIdx)) { right = rIdx }
            if (right !== undefined && left !== undefined) { break }
            lIdx += 1;
            rIdx -= 1;
        }
        data = undefined; // release RAM held
        can.width = 1; // release RAM held
        return right - left >= 1 ? {
            left, right, rightOffset: w - right,  width: right - left, 
            measuredWidth: w, font, baseSize} : undefined;
    }   
})();

用法示例

该示例使用上面的函数并通过仅提供第一个和最后一个非白色 space 字符来缩短测量。

在文本输入中输入文本。

  • 如果文本太大而不适合 canvas 控制台将显示警告。
  • 如果文本比例大于 1(意味着显示的字体大于测量的字体),控制台将显示警告,因为对齐精度可能会有所损失。

inText.addEventListener("input", updateCanvasText);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 500;

function updateCanvasText() {
    const text = inText.value.trim(); 
    const shortText = text[0] + text[text.length - 1];
    const txtSize = measureText({font: "arial", text: text.length > 1 ? shortText: text});
    if(txtSize) {
        ctx.clearRect(0,0,ctx.canvas.width, ctx.canvas.height)
        ctx.font = txtSize.font;
        const width = ctx.measureText(text).width;
        const actualWidth = width - txtSize.left - txtSize.rightOffset;
        const scale =  (canvas.width - 20) / actualWidth;
        console.clear();
        if(txtSize.baseSize * scale > canvas.height) {
            console.log("Font scale too large to fit vertically");
        } else if(scale > 1) {
            console.log("Scaled > 1, can result in loss of precision ");
        }
        ctx.textBaseline = "top";
        ctx.fillStyle = "#000";
        ctx.textAlign = "left";
        ctx.setTransform(scale, 0, 0, scale, 10 - txtSize.left * scale, 0);
        ctx.fillText(text,0,0);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.fillStyle = "#CCC8";
        ctx.fillRect(0, 0, 10, canvas.height);
        ctx.fillRect(canvas.width - 10, 0, 10, canvas.height);
    } else {
        console.clear();
        console.log("Empty string ignored");
    }
}
const measureText = (() => {
    var data, w, size =  120;
    const isColumnEmpty = x => {
       var idx = x, h = size * 2;
       while (h--) {
           if (data[idx]) { return false }
           idx += can.width;
       }
       return true;
    }
    const can = document.createElement("canvas");
    const ctx = can.getContext("2d");
    return ({text, font, baseSize = size}) => {   
        size = baseSize;
        can.height = size * 2;
        font = size + "px "+ font;          
        if (text.trim() === "") { return }
        ctx.font = font;
        can.width = (w = ctx.measureText(text).width) + 8;
        ctx.font = font;
        ctx.textBaseline = "middle";
        ctx.textAlign = "left";
        ctx.fillText(text, 0, size);
        data = new Uint32Array(ctx.getImageData(0, 0, can.width, can.height).data.buffer);
        var left, right;
        var lIdx = 0, rIdx = can.width - 1;
        while(lIdx < rIdx) {
            if (left === undefined && !isColumnEmpty(lIdx)) { left = lIdx }
            if (right === undefined && !isColumnEmpty(rIdx)) { right = rIdx }
            if (right !== undefined && left !== undefined) { break }
            lIdx += 1;
            rIdx -= 1;
        }
        data = undefined; // release RAM held
        can.width = 1; // release RAM held
        return right - left >= 1 ? {left, right, rightOffset: w - right, width: right - left, measuredWidth: w, font, baseSize} : undefined;
    }   
})();
body {
  font-family: arial;
}
canvas {
   border: 1px solid black;
   width: 500px;
   height: 500px;   
}
<label for="inText">Enter text </label><input type="text" id="inText" placeholder="Enter text..."/>
<canvas id="canvas"></canvas>

注意装饰字体可能不起作用,您可能需要在函数measureText

中扩展canvas的高度

我在我的一个项目中遇到了类似的问题。我不仅需要获得文本的确切宽度,而且我还意识到,如果我在位置 X 处呈现文本,由于 Side Bearings.

,它有时会流到 X 的左侧

尽我所能,我无法让 DOM 给我这些值,所以我不得不求助于 SVG 来准确测量文本。

我最终得到了以下解决方案来精确测量文本,包括侧方位或 X 偏移,我需要应用这些偏移才能使像素出现在正确的位置。

此代码仅在 Chrome 和 Firefox 中测试过,但 should work in basically all modern browsers。 还支持网页字体的使用,只需要加载到页面中,然后通过名称引用即可。

class TextMeasurer {
  constructor() {
    const SVG_NS = "http://www.w3.org/2000/svg";

    this.svg = document.createElementNS(SVG_NS, 'svg');

    this.svg.style.visibility = 'hidden';
    this.svg.setAttribute('xmlns', SVG_NS)
    this.svg.setAttribute('width', 0);
    this.svg.setAttribute('height', 0);

    this.svgtext = document.createElementNS(SVG_NS, 'text');
    this.svg.appendChild(this.svgtext);
    this.svgtext.setAttribute('x', 0);
    this.svgtext.setAttribute('y', 0);

    document.querySelector('body').appendChild(this.svg);
  }

  /**
   * Measure a single line of text, including the bounding box, inner size and lead and trail X
   * @param {string} text Single line of text
   * @param {string} fontFamily Name of font family
   * @param {string} fontSize Font size including units
   */
  measureText(text, fontFamily, fontSize) {
    this.svgtext.setAttribute('font-family', fontFamily);
    this.svgtext.setAttribute('font-size', fontSize);
    this.svgtext.textContent = text;

    let bbox = this.svgtext.getBBox();
    let textLength = this.svgtext.getComputedTextLength();

    // measure the overflow before and after the line caused by font side bearing
    // Rendering should start at X + leadX to have the edge of the text appear at X
    // when rendering left-aligned left-to-right
    let baseX = parseInt(this.svgtext.getAttribute('x'));
    let overflow = bbox.width - textLength;
    let leadX = Math.abs(baseX - bbox.x);
    let trailX = overflow - leadX;

    return {
      bbWidth: bbox.width,
      textLength: textLength,
      leadX: leadX,
      trailX: trailX,
      bbHeight: bbox.height
    };
  }
}

//Usage:
let m = new TextMeasurer();
let textDimensions = m.measureText("Hello, World!", 'serif', '12pt');
document.getElementById('output').textContent = JSON.stringify(textDimensions);
<body>
  <div id="output"></div>
</body>