看到这个标题的时候,很多人可能会像我一样,脑海里会一下子跳出CSS3的方案来。的确如此,CSS3是实现渐变文字最简单直接的方案,本站就有很多用CSS3实现的双色水平渐变的文字实例。我这里把描述突然从炫彩字转到了渐变字,两者的概念一样吗?我不知道,可能只是各个行业的叫法不一而已。我所在的行业是为LED显示屏提供云节目制作和发布服务的,行业内把这种文本称之为炫彩字媒体,比如大街上司空见惯的显示屏上写着诸如“欢迎光临”的五彩斑斓的文字。受阻于业务需要,CSS的方案是我们最先Pass掉的,为什么呢?

LED显示屏炫彩字
LED显示屏炫彩字

业务简介

如下图,是我们最终实现的功能。可整理为以下几点需求:

  • 大画布上有固定了宽高的盒子(绿色虚线框部分)
  • 支持150个字符以内文本,文本在盒子中不换行显示,这里就会遇到第一个问题 -- 文本截断。文本必须铺满盒子但是不允许半个文字的情况出现(英文允许单词内换行);
  • 炫彩字支持镂空(背景图片填充文字)、水平、垂直、斜角等一共10来种填充方案。
  • 右侧属性的改变要时时的反馈在左侧画布中,所见即所得。

按理说,无论从性能还是代码量来说,CSS3都是最优的方案,但是在实现过程中我却遇到了无法越过的问题 —— 行高

本文将从CSS、SVG、Canvas三种实现方案来阐述我在实现炫彩字过程中遇到的问题以及是如何解决的?

完整业务
完整业务

CSS3方案

抛开兼容性,CSS3是很容易实现渐变文字效果的。

.colorful-text {
  background-image: linear-gradient(90deg,color1,color2,...,colorN);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

background-image定义了文本盒子的背景填充样式,-webkit-background-clip:text用来让背景被裁剪成文字的前景样式,在通过-webkit-text-fill-color: transparent 是文本自身透明,呈现出被裁减的背景色

从业务出发,用CSS来分别实现水平5色、垂直5色的填充效果,先来定义一下HTML结构和基本样式:

<style>
  .colorful-wrap {
    display: inline-block;
    background-color: #000;
  }
  .colorful-text {
    display: inline-block;
    font-size: 4rem;
    font-weight: bold;
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }
</style>

<div class="colorful-wrap">
  <span class="colorful-text">炫彩字实现的几种方案</span>
</div>

这里使用了一个行内盒子.colorful-wrap在渐变文字最外层又包裹了一层,并且填充了黑色作为背景色,用来凸显文字所占据的盒子大小以及方便说明遇到的问题。

水平渐变五色
.colorful-text {
  background-image: linear-gradient(to right, red, aquamarine, green, yellow, blue);
}

CSS3 水平渐变五色效果
CSS3 水平渐变五色效果

垂直渐变五色

修改一下背景填充样式

.colorful-text {
  background-image: linear-gradient(to bottom, red, aquamarine, green, yellow, blue);
}

CSS3 垂直渐变五色效果
CSS3 垂直渐变五色效果

遇到的问题
垂直模式下的色彩丢失问题

很容易看到,垂直渐变出现问题了,red, aquamarine, green, yellow, blue五种颜色只能看见aquamarine, green, yellow三种颜色了,而最上层的red和最下层的blue两种颜色变得隐约可见(色弱的人可能都看不见),这种效果客户肯定是无法接受的。原因也显而易见,问题出现在盒子高度上,盒子的高度由文本撑起,而影响文本的只有行高(忽略字体影响)。至于line-height如何影响盒子高度,可以参考《深入了解CSS字体度量,行高和vertical-align》以及《css行高line-height的一些深入理解及应用》等文章。这里我们只是得到这样一个结论,修改行高可能会得到我们想要的效果

修改一下文本的行高,使其等于文字大小

.colorful-text {
  line-height: 1;
}

CSS3 垂直渐变五色效果 行高 = 1 中文
CSS3 垂直渐变五色效果 行高 = 1 中文

嗯,看起来完美解决了遇到的问题,再来尝试修改一下文案,把文案设置为英文。

<span class="colorful-text">good, happy new year</span>

CSS3 垂直渐变五色效果 行高 = 1 英文
CSS3 垂直渐变五色效果 行高 = 1 英文

问题变得更严重了,不仅颜色不均衡,像y|g|h等字符也被截断了,聋子被治成了哑巴。根据业务需要,我们不仅要支持各种字符的输入,也要支持各种字体的设置,字体同样会影响高度,而line-height:1也仅仅是解决了中文下的垂直渐变问题。

下划线问题

再来看下另外一个问题,业务要求文本支持下划线。暂时忽略垂直模式下的色彩丢失问题,来看一下水平模式下添加下划线的效果。

.colorful-text {
  /* 恢复行高 */
  /* line-height: 1; */
  background-image: linear-gradient(to right, red, aquamarine, green, yellow, blue);
  /* 设置下划线 */
  text-decoration: underline;
}

CSS3 水平渐变五色效果 下划线
CSS3 水平渐变五色效果 下划线

看起来毫无效果,可能有人认为是外层容器的背景色影响,来看下面这个Gif图(录制效果可能有些模糊):

CSS3 文字渐变对下划线的影响
CSS3 文字渐变对下划线的影响

看来是-webkit-text-fill-color: transparent影响了下划线,而该属性又是实现渐变文字效果不可获取的配角。实际上,在CSS渐变文字效果下,不仅下划线无效,删除线也是无效的。看来只能通过伪类来模拟了,这里暂时就不实现了,因为垂直模式下的色彩丢失问题已经让整个CSS方案都无意义了,而且,在最后的Canvas小节中,下划线依然是绕不过的问题,留着最后一并解决。

文本的截断问题

上面例子中,我故意忽略了第二点需求盒子有宽度(而且宽高也是任意可缩放的),文本可截断但不允许半个字出现。来看下这种情况:

.colorful-text {
  width: 300px;
  white-space: nowrap;
  overflow: hidden;
}

CSS3 文本被截断的问题
CSS3 文本被截断的问题

很明显出现了半个字的情况,我想到了三种解决思路:

  • 设置高度为单行文本高度,允许文字换行,这样多余的文字会自动掉在视区之外;
    .colorful-text {
      /* white-space: nowrap; */
      overflow: hidden;
      line-height: 1.4;
      /* height = font-size * line-height */
      height: 5.6rem;
    }
    固定高度下的文本截断问题
    固定高度下的文本截断问题

    中文下还好,英文状态下留的空白太多了,那些空间还可以放下几个字符(英文我们是允许单词内换行的)
  • 利用多行文本截断的属性-webkit-line-clamp: 1;来实现
    .colorful-text {
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 1;
      overflow: hidden;
    }
    line-clamp方式的文本截断问题
    line-clamp方式的文本截断问题

    问题依旧,同时又多了中文未占满空间的问题
  • JS动态计算区域里能够显示下的文字(可行方案,具体实现同 canvas方案)

SVG方案

待补充

Canvas方案

同CSS和SVG一样,Canvas同样会遇到垂直模式下的色彩丢失问题。这是文字本身所限制的。但如果你了解安卓,可能对下面这些图不会陌生。

Android-FontMetrics
Android-FontMetrics

Android-getTextBounds
Android-getTextBounds

安卓中有这样一个用来度量文本的API叫做FontMetrics,同时还有一个getTextBounds的度量方法,能够获取到文本的边界信息。何为边界,就是能够包裹文字的最小矩形,如上图中红色边框框起来的那部分,这就是我们想要的文字的宽高。

回到Canvas,我们知道Html5 Canvas对象可以通过CanvasRenderingContext2D.measureText()接口获取文本尺寸,但很遗憾的是除了width属性,其他属性我们都无法直接获取(Safari浏览器支持,Chrome浏览器需要用户明确启用,兼容性见https://caniuse.com/#search=TextMetrics)。

我在Github上找到一个这样的库fontmetrics.js,它利用Dom辅助以及Canvas像素扫描的方式变相的实现了Canvas文字的完整度量接口。

实现思路

有了文字的度量方法,使用canvas实现起来就相对简单了:

  • 创建一个空的Canvas对象并且填充到父级容器中,此时的Canvas对象没有设置尺寸,使用的是浏览器默认的尺寸;
  • 通过用户输入,设置Canvas的字体(font-family, font-size, font-weight, font-style)等属性;
  • 通过Ctx(canvas.getContext('2d'))原生的measureText方法,获取父级容器中可放置的最大字符串renderTexts
  • 通过fontmetrics.js提供的度量方法获取renderTexts的文本度量属性;
  • 创建一个辅助Canvas,用来生成包括图像填充和各种渐变填充的辅助图案pattern
  • 设置第一步创建的Canvas对象的宽高为度量后的文本宽高;
  • 设置Ctx的填充样式为辅助Canvas返回的pattern;
  • 通过Ctx的fillText方法填充renderTexts

最终的DOM结构如下:

<div class="canvas">
  <div class="media-item" style="width: 300px; height: 200px;">
    <!-- 炫彩字文本图片, 垂直居中在父级容器内 -->
    <canvas width="290" height="60"></canvas> 
  </div>
</div>  
获取可视区域内可放置的最大字符

首先要基于需求二,在文本不换行的情况下,获取盒子内最大可放置的字符串,这里比较简单,一个字符一个字符的遍历文本,直到渲染的文本总宽度即将超出盒子的宽度。

  /**
   * 获取可渲染的字符串
   * @param {*} text   用户输入的文本
   * @param {*} maxWidth 盒子对的宽度
   * @param {*} fontStyle 用户设置的font属性组成的字符串
   */
  const getRenderTexts = (text, maxWidth, fontStyle = 'bold 60px "Microsoft Yahei"') => { 
    ctx.font = fontStyle
    const chars = text.split('')
    let renderTexts = ''
    let width
    for (let n = 0; n < chars.length; n++) {
      const words = lineWords + chars[n]
      // 原生的measureText方法
      const metrics = ctx.measureText(words)
      width = metrics.width
      if (width > maxWidth) {
        break
      }
      renderTexts = words
    }
    return renderTexts
  }
获取填充样式辅助图案

为了兼容图片填充(镂空),该方法使用Promise。方法中的widdth/height参数是度量后的文本的尺寸,这样能保证填充图案完整的覆盖文字。

const PATTERN_TYPES = {
  // 镂空
  IMAGE: 1,
  // 水平渐变
  GRADIENT_HORIZONTAL: 2,
  // 垂直渐变
  GRADIENT_VERTICAL: 3,
  // 斜角渐变
  GRADIENT_OBLIQUE: 4
}

/**
 * 获取填充样式
 * @param {*} width 文本宽度
 * @param {*} height 文本高度
 * @param {*} type 填充类型
 * @param {*} source 图片src或者颜色数组
 */
const getPattern = (width, height, type, source = '') => {
  return new Promise((resolve) => {
    // 创建一个辅助Canvas
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    canvas.width = width // 文本的宽度
    canvas.height = height // 文本的高度

    // 镂空
    if (type === PATTERN_TYPES.IMAGE) {
      let reader
      reader = new Image()
      reader.src = source
      reader.onload = reader.onerror = function () {
        try {
          ctx.drawImage(reader, 0, 0, width, height)
          resolve({
            pattern: ctx.createPattern(canvas, 'no-repeat'),
            canvas,
            ctx
          })
        } catch (error) {
          resolve({
            // 出错了就填充成白色
            pattern: '#fff',
            canvas,
            ctx
          })
        }
        reader.onload = null
        reader.onerror = null
        reader = null
      }
    }
    // 颜色渐变
    else {
      let gradient
      switch (type) {
        // 水平 左 - 右
        case PATTERN_TYPES.GRADIENT_HORIZONTAL:
          gradient = ctx.createLinearGradient(0, 0, width, 0)
          break
        // 垂直  上 - 下
        case PATTERN_TYPES.GRADIENT_VERTICAL:
          gradient = ctx.createLinearGradient(0, 0, 0, height)
          break
        // 斜角  左上角-右下角
        case PATTERN_TYPES.GRADIENT_OBLIQUE:
          gradient = ctx.createLinearGradient(0, 0, width, height)
          break
      }

      // 设置渐变颜色步进(0 - 1)
      gradient.addColorStop(0, source[0])
      const size = source.length - 1
      for (let i = 1; i < size; i++) {
        gradient.addColorStop(i / size, source[i])
      }
      resolve({
        pattern: gradient,
        canvas,
        ctx
      })
    }
  })
}
度量文本并绘制炫彩字

fontmetrics.js的作者是通过CanvasRenderingContext2D.prototype.measureText = xxx的覆盖方式来重写了measureText方法,我在使用时,将它重修修改成了一个普通方法canvasMeasureText

/**
 * 渲染炫彩字到Canvas
 * @param {*} canvas 炫彩字画布
 * @param {*} $wrap  炫彩字画布父级容器
 * @param {*} param2 
 */
const render = (canvas, $wrap, {text, fontStyle, type, source} = options) => {
  const ctx = canvas.getContext('2d')
  // 设置字体样式
  ctx.font = fontStyle
  // 容器的宽度,也就是包裹canvas元素的宽度
  const w = $wrap.offsetWidth
  // 获取可渲染的文字
  const renderTexts = getRenderTexts(text, fontStyle, w)
  // 通过fontmetrics.js来度量文本
  const metrics = canvasMeasureText(renderTexts)
  const rect = {
    x: 0,
    y: h / 2 - metrics.ascent,
    w: Math.ceil(metrics.width),
    h: Math.ceil(1 + metrics.bounds.maxy - metrics.bounds.miny)
  }

  const width = rect.w
  const height = rect.h
  // 设置canvas的宽高为文本宽高
  canvas.width = width
  canvas.height = height
  getPattern(width, height, type, source)
    .then(({
      pattern,
      canvas: helpCanvas,
      ctx: helpCtx
    }) => {
      // 设置canvas的填充样式
      ctx.fillStyle = pattern
      // 填充文字
      ctx.fillText(renderTexts, 0, metrics ? metrics.ascent : 0)
      // 销毁辅助对象
      helpCanvas = helpCtx = null
    })
}

遇到的问题

无法绕过的下划线问题

不可避免的又遇到了下划线的问题,Canvas本身是没有提供文本下划线相关API的,这里只能通过矩形模拟的方式来实现了。矩形模拟的话,矩形的高就代表了下划线的粗细,而目前没有直接的方法来获取不同字体、字号下下划线的粗细。所以在用CSS测试了包括部分字体、字重以及字体大小的文本在加下划线后的效果,并且手动度量了下划线对的粗细后,大概得出这样一个相对来说能忽悠过需求的公式(至少在表现上):underlineHeight = Math.max(1, Math.round(fontSize / 16))

修改下render方法,来兼容下划线

const render = (canvas, $wrap, {text, fontStyle, type, source, underline, fontSize} = options) => {
  // ...
  const rect = {
    x: 0,
    y: h / 2 - metrics.ascent,
    w: Math.ceil(metrics.width),
    h: Math.ceil(1 + metrics.bounds.maxy - metrics.bounds.miny)
  }
  let underlineHeight = 0
  if (underline) {
    underlineHeight = Math.max(1, Math.round(fontSize / 16))
  }

  const width = rect.w
  // 在文本的基础上加上下划线高度, 留1个像素的间隙
  const height = rect.h + (underlineHeight ? (underlineHeight + 1) : 0)
  canvas.width = width
  canvas.height = height
  getPattern(width, height, type, source)
    .then(({
      pattern,
      canvas: helpCanvas,
      ctx: helpCtx
    }) => {
      // 设置canvas的填充样式
      ctx.fillStyle = pattern
      // 填充文字
      ctx.fillText(renderTexts, 0, metrics ? metrics.ascent : 0)
      // 如果有下划线,则绘制一个矩形
      if (underlineHeight > 0) {
        // y轴从文本下方开始
        ctx.fillRect(0, height - underLineHeight, width, underlineHeight)
      }
      // 销毁辅助对象
      helpCanvas = helpCtx = null
    })
}

最终还是使用Canvas的方式实现了炫彩字媒体,最终的效果:

图案镂空效果
图案镂空效果

水平渐变6色效果
水平渐变6色效果

垂直渐变6色效果
垂直渐变6色效果

可以看到,无论是图案还是颜色都比较均匀的分布在了文字上,在有下划线时,垂直渐变的最后一个颜色也是完整占据了下划线,基本做到了可交付的效果。

-- EOF --

本文标题:炫彩字实现的几种方案

本文链接:https://smohan.net/blog/ssquhh

本站使用「 署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 」创作共享协议,转载或使用请署名并注明出处。 相关说明 »

更多文章

评论 「 ... 」