【问题标题】:How to fit text to a precise width on html canvas?如何在 html 画布上将文本调整为精确的宽度?
【发布时间】:2020-06-06 09:18:17
【问题描述】:

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

下面是一些示例代码:

// 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",边缘有一个间隙,你可以看到文本没有到达“护栏”:

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

【问题讨论】:

  • 据我现在测试,它首先取决于字体大小,其次取决于字母。一种可能性是计算字母的大小和更大的字体大小或更小的边缘空间。

标签: html text html5-canvas measurement


【解决方案1】:

我在我的一个项目中遇到了类似的问题。我不仅需要获得文本的确切宽度,而且我还意识到如果我在位置 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>

【讨论】:

  • 感谢分享!我曾尝试过类似的方法,但被困在计算侧轴承上。我最终使用npmjs.com/package/text-to-svg 将文本转换为 SVG 路径,因为使用路径您可以准确地测量 bbox 而无需任何填充。您的解决方案更简单,因为它不需要任何库。
【解决方案2】:

测量文字宽度

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

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

仅使用width

您最多可以使用ctx.measureText 返回的对象的width 属性来估计宽度。此宽度大于或等于实际像素宽度(从左到右最)。注意网络字体必须完全加载,否则宽度可能是占位符字体的宽度。

蛮力

不幸的是,唯一能可靠工作的方法是一种蛮力技术,它将字体渲染到临时/或工作画布上,并通过查询像素来计算范围。

这将适用于支持画布的所有浏览器。

不适合实时动画和应用。

如下函数

  • 将返回具有以下属性的对象

    • width 文本的画布像素宽度
    • left 距画布像素中第一个像素左侧的距离
    • right 画布像素中从左侧到最后检测到的像素的距离
    • rightOffset 画布像素与测量的文本宽度和检测到的右边缘的距离
    • measuredWidthctx.measureText 返回的测量宽度
    • baseSize 字体大小(以像素为单位)
    • font用于测量文本的字体
  • 如果宽度为零或字符串不包含可见文本,它将返回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;
    }   
})();

使用示例

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

在文本输入中输入文本。

  • 如果文本太大而无法适应画布,控制台将显示警告。
  • 如果文本比例大于 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中扩展画布高度

【讨论】:

  • 确实不要在动画中使用它。 Chrome 最近通过将上下文保存在 GPU 中来杀死他们的 getImageData 性能。见bugs.chromium.org/p/chromium/issues/detail?id=1001845。 Chrome 中还没有,但他们应该很快就会实现像 FF 一样的 willReadFrequently 标志,您可能希望已经为您的屏幕外画布设置它。
  • 感谢您的提醒。在动画中不使用它的原因会是动画会卡顿吗?我认为在这个答案中每次击键都可以更新它?
  • @toby-one 我将取决于设备。如果您每帧调用该函数,低端设备将会受到影响。高端设备每帧调用该函数几次都没有问题。注意,如果文本的首尾字符没有变化,字体也没有变化,只需要在启动时调用一次函数,使用测量来适应任意宽度即可。如快捷示例所示(第一个sn-p)
【解决方案3】:

您面临的问题是TextMetrics.width 代表文本的“advance width”。
This answer 很好地解释了它是什么,并链接到good resources

前进宽度是字形的初始笔位置和下一个字形的初始笔位置之间的距离。

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

不幸的是,所有浏览器都不支持扩展的 TextMetrics 对象,实际上只有 Chrome 支持,因为 Safari 错误地返回了 advance width 的边界框值.对于其他浏览器,我们不走运,不得不依赖丑陋的 getImageData hack。

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>

【讨论】:

    猜你喜欢
    • 2019-05-23
    • 1970-01-01
    • 2017-08-11
    • 2021-12-05
    • 1970-01-01
    • 1970-01-01
    • 2021-12-13
    • 2014-05-15
    • 2023-03-19
    相关资源
    最近更新 更多