JavaScript实现文本溢出自动缩小字体到N行适应容器

LaiTaoGDUT -
JavaScript实现文本溢出自动缩小字体到N行适应容器

当一个页面需要切换不同的语言时,同一句文案,不同语言的文本长度很难保持一致,有的时候,文本无限制换行显示会影响整个页面布局,因此需要使用一些方法来确保文本容器在不同的语言中都保持布局不变形,也就是将文本限制在N行以内显示,保证不把容器撑开也不溢出容器的同时,尽可能在有限的空间里显示更多文本。
image.png
(设计稿给出文本最多一行)
image.png
(实际页面在英文文案下变成了两行)
如何将文本限制在N行显示,通常会用以下几种方法:

文本超出N行显示滚动条文本超出N行显示省略号文本超出N行将字号设置为某个更小的字号,如果还是超出则显示省略号文本超出N行将字号缩小至刚好铺满容器,最小缩小到某个字号,如果还是超出则显示省略号

第1、2种方法通过设置html和css就可以办到了,比较简单,文章主要探讨第3、4种方法的可行性以及实现过程。

为了方便操作,先将文本套上一个span标签再放到容器(container)中,比如<div><span>text</span></div>,并让容器的高度始终小于等于文本内容的高度或由文本内容撑开,然后我们就可以对容器进行字号的设置操作。

文本溢出判断

不管是文本超过N行设置固定字号还是自动调整字号,都需要先判断准确文本是否超出,判断溢出的方法有很多,在这里我们通过拿到文本当前高度与N行文本允许的最大高度进行比较。文本的当前高度可以通过container.scrollHeight获得,N行文本允许的最大高度则可以通过文本的行高lineHeight * N获得(单行文本的高度是由文本的行高决定的),最后比较两者大小得出文本溢出情况。

/**
 * @param {Number} lineNum 最大允许的文本行数
 * @param {html element} containerEle 文本容器
 * @returns Boolean 返回文本是否溢出最大允许的文本函数
 */
function isTextOverflow(lineNum, containerEle) {
  let isOverflow = false;
  const computedStyle = getComputedStyle(containerEle);

  if (!/^\d+/.test(computedStyle.lineHeight)) {
    throw new Error('container\'s lineHeight must be a exact value!');
  }

  const lineHeight = Number(computedStyle.lineHeight.slice(0, -2));

  if (containerEle.scrollHeight > Math.ceil(lineHeight * lineNum)) {
    isOverflow = true;
  }
  return isOverflow;
}

首先通过getComputedStyle方法拿到scrollHeightlineHeight,注意使用这种方法需要容器的lineHeight为明确的值,如果容器的lineHeight被省略或者设置为关键字,就无法获取具体的数值,最后再将两者的比较结果返回即可。

N行文本缩小字号适应容器

如果问题变成单行文本缩小字号适应容器,就变得简单得多了,我们只需要调整字号使得文本内容的宽度和容器的宽度刚好相等即可,也就是需要拿到当前文本字号fontSize,当前文本未折行时的宽度textWidth,当前容器宽度containerWidth,目标字号 = fontSize * containerWidth / textWidth

要获取文本未折行时的宽度,最简单也最容易想到的办法就是先将容器的whiteSpace设置为nowrap,等待浏览器重排重绘后获取未折行的宽度,再将whiteSpace重制,或者创建一个额外的whiteSpacenowrap的复制元素插入dom中,等待浏览器重排重绘后获取。通过这个方式我们可以很容易写出单行文本自动缩小字号适应容器的方法。

/**
 * @param {*} containerEle 文本容器
 * @param {*} minFontSize 限制最小可以缩小到的字号
 * @returns 
 */
 function adjustFontSizeSingle(containerEle, minFontSize = 8) {
  return new Promise(async (resolve) => {
    if (!isTextOverflow(1, containerEle)) {
      resolve();
      return;
    }

    const computedStyle = getComputedStyle(containerEle);
  
    const needResetWhiteSpace = computedStyle.whiteSpace;
    const needResetOverflow = computedStyle.overflow;
    const fontSize = Number(computedStyle.fontSize.slice(0, -2));
  
    // 设置文本不折行以计算文本总长度
    containerEle.style.whiteSpace = 'nowrap';
    containerEle.style.overflow = 'hidden';
  
    await nextTick();
  
    const textBody = containerEle.childNodes[0];
    if (containerEle.offsetWidth < textBody.offsetWidth) {
      // 按比例缩小字号到刚好占满容器
      containerEle.style.fontSize = `${Math.max(
        fontSize * (containerEle.offsetWidth / textBody.offsetWidth),
        minFontSize
      )}px`;
    }
  
    containerEle.style.whiteSpace = needResetWhiteSpace;
    containerEle.style.overflow = needResetOverflow;
    resolve();
  });
}

await adjustFontSizeSingle(ele);
console.log('调整完成');

image.png
调整前
image.png
调整后

演示》》

用上面的方法需要进行一次额外的dom操作,那么能不能省下这一次dom操作呢,我们的可以用CanvasRenderingContext2D.measureText()来计算文本未折行时的宽度。通过给canvas的画笔设置相同的字号与字体,再调用measureText,即可得到与原生dom相同的单行字符串宽度。

/**
 * @param {*} containerEle 文本容器
 * @param {*} minFontSize 限制最小可以缩小到的字号
 * @param {*} adjustLineHeight 是否按比例调整行高
 * @returns 
 */
async function adjustFontSizeSingle(containerEle, minFontSize = 16, adjustLineHeight = false) {
  let isOverflow = false;
  const computedStyle = getComputedStyle(containerEle);

  if (!/^\d+/.test(computedStyle.lineHeight)) {
    throw new Error('container\'s lineHeight must be a exact value!');
  }

  let lineHeight = Number(computedStyle.lineHeight.slice(0, -2));
  let fontSize = Number(computedStyle.fontSize.slice(0, -2));

  if (isTextOverflow(1, containerEle)) {
    isOverflow = true;
  }

  if (!isOverflow) {
    return;
  }

  const textBody = containerEle.childNodes[0];

  if (!offCtx) {
    offCtx = document.createElement('canvas').getContext('2d');
  }
  const { fontFamily } = computedStyle;
  offCtx.font = `${fontSize}px ${fontFamily}`;
  const { width: measuredWidth } = offCtx.measureText(textBody.innerText);

  if (containerEle.offsetWidth >= measuredWidth) {
    return;
  }

  let firstTransFontSize = fontSize;
  let firstTransLineHeight = lineHeight;
  firstTransFontSize = Math.max(
    minFontSize,
    fontSize * (containerEle.offsetWidth / measuredWidth)
  );
  firstTransLineHeight = firstTransFontSize / fontSize * lineHeight;

  fontSize = firstTransFontSize;
  containerEle.style.fontSize = `${fontSize}px`;
  if (adjustLineHeight) {
    lineHeight = firstTransLineHeight;
    containerEle.style.lineHeight = `${lineHeight}px`;
  }
  console.log('溢出调整完成');
}

演示 》》

当问题上升到多行文本时,或许我们可以仿造单行文本那样通过计算(容器宽度 * 行数)与(文本未折行的总宽度)的比来得到文本需要缩小的比例,但却没有这么简单,因为文本的换行并不是简单的将字符串等分切开放在每一行,而会遵循排版换行的规则,具体规则参考Unicode 换行算法 (Unicode Line Breaking Algorithm, UAX #14)

Unicode 换行算法描述了这样的算法:给定输入文本,该算法将产生被称为换行机会(break opportunities)的一组位置,换行机会指的是在文本渲染的过程中允许于此处换行,不过实际换行位置需要结合显示窗口宽度和字体大小由更高层的应用软件另行确认。

也就是说文本并不是在每一个字符都可以换行的,它会在通过算法得到的有最近换行机会的字符换行,浏览器当然也遵守了这个规则,此外也能通过css3来自定义一些换行规则

line-break:用来处理如何断开带有标点符号的中文、日文或韩文(CJK)文本的行word-break:指定怎样在单词内断行hyphens:告知浏览器在换行时如何使用连字符连接单词overflow-wrap:用来说明当一个不能被分开的字符串太长而不能填充其包裹盒时,为防止其溢出,浏览器是否允许这样的单词中断换行。

好在我们并不需要完全精准地计算刚好能占满容器的字号,可以先通过计算(容器宽度 * 行数)与(文本未折行的总宽度)的比初步得到文本需要缩小到的字号,受益于换行规则,此时文本依然可能有溢出的情况发生,但离刚好占满容器需要缩小到的字号距离很近了,通过有限的几次循环缩小字号即可在一定误差范围内得到我们想要的字号。

async function adjustFontSizeLoop(lineNum, containerEle, step = 1, minFontSize = 8, adjustLineHeight = false) {
  let isOverflow = false;
  const computedStyle = getComputedStyle(containerEle);

  if (!/^\d+/.test(computedStyle.lineHeight)) {
    throw new Error('container\'s lineHeight must be a exact value!');
  }

  let lineHeight = Number(computedStyle.lineHeight.slice(0, -2));
  let fontSize = Number(computedStyle.fontSize.slice(0, -2));
  if (containerEle.scrollHeight <= Math.ceil(lineHeight * lineNum)) {
    return;
  }

  const textBody = containerEle.childNodes[0];

  if (!offCtx) {
    offCtx = document.createElement('canvas').getContext('2d');
  }
  const { fontFamily } = computedStyle;
  offCtx.font = `${fontSize}px ${fontFamily}`;
  const { width: measuredWidth } = offCtx.measureText(textBody.innerText);
  if (containerEle.offsetWidth * lineNum >= measuredWidth) {
    return;
  }

  let firstTransFontSize = fontSize;
  let firstTransLineHeight = lineHeight;
  firstTransFontSize = Math.max(
    minFontSize,
    fontSize * (containerEle.offsetWidth * lineNum / measuredWidth)
  );
  firstTransLineHeight = firstTransFontSize / fontSize * lineHeight;

  fontSize = firstTransFontSize;
  containerEle.style.fontSize = `${fontSize}px`;
  if (adjustLineHeight) {
    lineHeight = firstTransLineHeight;
    containerEle.style.lineHeight = `${lineHeight}px`;
  }

  if (lineNum === 1) {
    return;
  }
  
  let runTime = 0;
  do {
    await nextTick();
    if (containerEle.scrollHeight > Math.ceil(lineHeight * lineNum)) {
      isOverflow = true;
    } else {
      isOverflow = false;
    }
    if (!isOverflow) {
      break;
    }
    runTime += 1;
    const transFontSize = Math.max(fontSize - step, minFontSize);
    if (adjustLineHeight) {
      lineHeight = this.toFixed(transFontSize / fontSize * lineHeight, 4);
      containerEle.style.lineHeight = `${lineHeight}px`;
    }
    fontSize = transFontSize;
    containerEle.style.fontSize = `${fontSize}px`;
  } while (isOverflow && fontSize > minFontSize);
  console.log('溢出调整完成, 循环设置字号:', runTime, '次');
}

演示 》》
image.png
调整前
image.png
调整为限制2行显示

下一步?

现在已经可以不那么完美的解决这个问题了(有不确定循环次数的代码总是不太能让人心安),而且看起来效率和效果也过得去。有没有能一次就算出最终需要缩小的字号呢?

我们可以先尝试得到一段文本在何处换行的信息,当然自己是没有这么多精力去手动实现换行规则,但是我们可以站在巨人的肩膀上,用别人实现好的开源库:niklasvh/css-line-break。

image.png
image.png

得到换行信息之后该怎么计算呢?让我再思考一会。

特别申明:本文内容来源网络,版权归原作者所有,如有侵权请立即与我们联系(cy198701067573@163.com),我们将及时处理。

Tags 标签

javascript前端html5

扩展阅读

加个好友,技术交流

1628738909466805.jpg