首页 > 解决方案 > 如何在不向 DOM 渲染任何内容的情况下计算文本高度?

问题描述

我正在利用一个虚拟化列表(react-virtualized),其中我的列表项的高度是必需的,并且可能会有很大差异。由于变化很大,我给图书馆的任何高度估计都会产生糟糕的体验。

计算高度的常用方法是这样的:

const containerStyle = {
  display: "inline-block",
  position: "absolute",
  visibility: "hidden",
  zIndex: -1,
};

export const measureText = (text) => {
  const container = document.createElement("div");
  container.style = containerStyle;

  container.appendChild(text);

  document.body.appendChild(container);

  const height = container.clientHeight;
  const width = container.clientWidth;

  container.parentNode.removeChild(container);
  return { height, width };
};

不幸的是,当您处理包含不同大小项目的超大列表时,这并不高效。虽然可以利用缓存,但当您需要在一开始就知道总高度(所有项目的总高度)时,即使这样也不能很好地发挥作用。

第二种经常使用的解决方案是通过 HTML canvas' measureText。性能类似于上面的 DOM 操作。

就我而言,我知道以下内容:

我正在寻找的是一个可以计算高度(或非常接近的估计值)的数学解决方案,这样我就不必依赖任何 DOM 操作,并且我可以随时获得高度。

我想它是这样的:

const measureText = (text, options) => {
  const { width, font, fontSize, padding, margins, borders, lineHeight } = options;

  // Assume this magical function exists
  // This all depends on width, stying and font information
  const numberOfLines = calculateLines(text, options);

  const contentHeight = numberOfLines * lineHeight;

  const borderHeight = borders.width * 2 // (this is all pseudo-code... but somehow get the pixel thickness. 

  const marginsHeight = margins.top + margins.bottom
  const paddingHeight = padding.top + padding.bottom

  return marginsHeight + paddingHeight + borderHeight + contentHeight;
}

在上面,我们缺少calculateLines功能,这似乎是工作的主要部分。一个人将如何在这方面前进?我需要做一些预处理来确定字符宽度吗?既然我知道我正在使用的字体,这应该不是太大的问题,对吧?

是否存在浏览器问题?每个浏览器的计算可能有何不同?

还有其他参数需要考虑吗?例如,如果用户有一些系统设置可以为他们放大文本(可访问性),浏览器是否通过任何可用数据告诉我这一点?

我知道渲染到 DOM 是最简单的方法,但我愿意将精力投入到公式化的解决方案中,即使这意味着每次我更改边距等时。我需要确保更新函数的输入。

更新:这可能有助于寻找字符宽度的路径:Static character width map calibrated via SVG bounding box。以下有更多信息:演示和详细信息。学分去托普

更新2:通过使用等宽字体,宽度计算变得更加简单,因为您只需要测量一个字符的宽度。令人惊讶的是,列表中有一些非常漂亮和流行的字体,如 Menlo 和 Monaco。

大更新 3:这是一个通宵,但通过更新 1 中的 SVG 方法的灵感,我想出了一个非常有效的方法来计算行数。不幸的是,我已经看到 1% 的时间它偏离了 1 行。大致代码如下:

const wordWidths = {} as { [word: string]: number };

const xmlsx = const xmlsn = "http://www.w3.org/2000/svg";

const svg = document.createElementNS(xmlsn, "svg");
const text = document.createElementNS(xmlsn, "text");
const spaceText = document.createElementNS(xmlsn, "text");
svg.appendChild(text);
svg.appendChild(spaceText);

document.body.appendChild(svg);

// Convert style objects like { backgroundColor: "red" } to "background-color: red;" strings for HTML
const styleString = (object: any) => {
  return Object.keys(object).reduce((prev, curr) => {
    return `${(prev += curr
      .split(/(?=[A-Z])/)
      .join("-")
      .toLowerCase())}:${object[curr]};`;
  }, "");
};

const getWordWidth = (character: string, style: any) => {
  const cachedWidth = wordWidths[character];
  if (cachedWidth) return cachedWidth;

  let width;

  // edge case: a naked space (charCode 32) takes up no space, so we need
  // to handle it differently. Wrap it between two letters, then subtract those
  // two letters from the total width.
  if (character === " ") {
    const textNode = document.createTextNode("t t");
    spaceText.appendChild(textNode);
    spaceText.setAttribute("style", styleString(style));
    width = spaceText.getBoundingClientRect().width;
    width -= 2 * getWordWidth("t", style);
    wordWidths[" "] = width;
    spaceText.removeChild(textNode);
  } else {
    const textNode = document.createTextNode(character);
    text.appendChild(textNode);
    text.setAttribute("style", styleString(style));
    width = text.getBoundingClientRect().width;
    wordWidths[character] = width;
    text.removeChild(textNode);
  }

  return width;
};

const getNumberOfLines = (text: string, maxWidth: number, style: any) => {
  let numberOfLines = 1;

  // In my use-case, I trim all white-space and don't allow multiple spaces in a row
  // It also simplifies this logic. Though, for now this logic does not handle
  // new-lines
  const words = text.replace(/\s+/g, " ").trim().split(" ");
  const spaceWidth = getWordWidth(" ", style);

  let lineWidth = 0;
  const wordsLength = words.length;

  for (let i = 0; i < wordsLength; i++) {
    const wordWidth = getWordWidth(words[i], style);

    if (lineWidth + wordWidth > maxWidth) {
      /**
       * If the line has no other words (lineWidth === 0),
       * then this word will overflow the line indefinitely.
       * Browsers will not push the text to the next line. This is intuitive.
       *
       * Hence, we only move to the next line if this line already has
       * a word (lineWidth !== 0)
       */
      if (lineWidth !== 0) {
        numberOfLines += 1;
      }

      lineWidth = wordWidth + spaceWidth;
      continue;
    }

    lineWidth += wordWidth + spaceWidth;
  }

  return numberOfLines;
};

最初,我是逐个字符进行的,但由于字距调整以及它们如何影响字母组,逐字逐句更准确。同样重要的是要注意,尽管使用了样式,但必须在maxWidth参数中考虑填充。CSS Padding 不会对 SVG 文本元素产生任何影响。它很好地处理了宽度调整样式letter-spacing(它并不完美,我不知道为什么)。

至于国际化,除了我学中文的时候,它似乎和英语一样好用。我不懂中文,但它似乎遵循不同的规则来溢出到新行,这并没有解释这些规则。

不幸的是,就像我之前说的,我注意到这是不时发生的。虽然这种情况不常见,但并不理想。我试图找出导致微小差异的原因。

我正在使用的测试数据是随机生成的,并且在 4~80 行之间(我一次生成 100 行)。

更新 4:我认为我不再有任何负面结果。更改是微妙但重要的:getNumberOfLines(text, width, styles)您需要使用getNumberOfLines(text, Math.floor(width), styles)并确保Math.floor(width)在 DOM 中也使用宽度,而不是 。浏览器不一致并且以不同的方式处理十进制像素。如果我们强制宽度为整数,那么我们不必担心它。

标签: javascripthtmldomreact-virtualized

解决方案


我找到了测量文本算法,它可以在不接触 DOM 的情况下近似字符串的宽度。

我对其进行了一些修改以计算行数(卡住的地方)。

您可以计算如下所示的行数:

/**
 * @param text : <string> - The text to be rendered.
 * @param containerWidth : <number> - Width of the container where dom will be rendered. 
 * @param fontSize : <number> - Font size of DOM text
**/

function calculateLines(text, containerWidth, fontSize = 14) {
  let lines = 1;  // Initiating number of lines with 1

// widths & avg value based on `Helvetica` font.
  const widths = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.278125,0.278125,0.35625,0.55625,0.55625,0.890625,0.6671875,0.1921875,0.334375,0.334375,0.390625,0.584375,0.278125,0.334375,0.278125,0.303125,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.278125,0.278125,0.5859375,0.584375,0.5859375,0.55625,1.015625,0.6671875,0.6671875,0.7234375,0.7234375,0.6671875,0.6109375,0.778125,0.7234375,0.278125,0.5,0.6671875,0.55625,0.834375,0.7234375,0.778125,0.6671875,0.778125,0.7234375,0.6671875,0.6109375,0.7234375,0.6671875,0.9453125,0.6671875,0.6671875,0.6109375,0.278125,0.35625,0.278125,0.478125,0.55625,0.334375,0.55625,0.55625,0.5,0.55625,0.55625,0.278125,0.55625,0.55625,0.2234375,0.2421875,0.5,0.2234375,0.834375,0.55625,0.55625,0.55625,0.55625,0.334375,0.5,0.278125,0.55625,0.5,0.7234375,0.5,0.5,0.5,0.35625,0.2609375,0.3546875,0.590625]
  const avg = 0.5293256578947368

  text.split('')
    .map(c => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg)
    .reduce((cur, acc) => {
      if((acc + cur) * fontSize  > containerWidth) {
          lines ++;
          cur = acc;
      }
      return acc + cur;
    }); 

  return lines;
}

笔记

我用Helveticaas font-family,你可以根据你的Measure textwidths获取&的值。avgfont-family


推荐阅读