生成前端海报的 N 种方案和优劣

    |     2021年4月25日   |   html/css/js, web前端技术   |     0 条评论   |    1029

业务里经常要生成分享海报:PC 分享图、H5 优惠券、年度账单。需求不同,方案也不同——第三方库、自己用 Canvas 画、丢给接口。各自都踩过跨域、字体、清晰度。

下面按 html2canvas、dom-to-image、前端 Canvas、接口出图整理兼容性和取舍。过程中的坑(跨域、文字、伪类)尽量写全,方便对照。

一、背景与业务

PC 侧主要是分享海报;H5 还有分享优惠券、年度账单、分享海报。实现时兼容性、还原度、性能要一起权衡。

二、html2canvas

版本 1.0.0-rc7。仓库 https://github.com/niklasvh/h… star 约 22k(统计于 2021-01-06)。首页 demo:https://html2canvas.hertzen.com/

20210425084503

兼容性抽测

  • 安卓:6.0 koobee ✅;5.0.2 vivo Y51A ✅
  • 安卓微信内置:7.0.3 ✅;7.0.22 ✅
  • iOS:14.2 ✅;11.2.1 ✅
  • iOS 微信内置:7.0.20 ✅;7.0.1 ✅

官方对浏览器的描述(需 Promise polyfill):

Browser compatibility
The library should work fine on the following browsers (with Promise polyfill):

Firefox 3.5+
Google Chrome
Opera 12+
IE9+
Safari 6+
As each CSS property needs to be manually built to be supported, there are a number of properties that are not yet supported.

优点:兼容面更宽。缺点:不少 CSS 转不过去——伪类、border dash、text-shadow 都不支持。还原度要和设计对齐,或改成切图。特性说明:https://html2canvas.hertzen.c…

rc5 在 iOS 上出现过生成不了图,退回 rc4 就好。https://github.com/niklasvh/h… issue 里有 iOS 某版本微信失败的反馈,当时没复现。

三、dom-to-image

版本 2.6.0。https://github.com/tsayen/dom… star 约 6.9k。

优点是元素齐、还原度高。缺点是不兼容 Safari,建议只在 Chrome 用,而且已经不再维护。

作者明确说不支持 Safari,原因是 foreignObject 的安全限制:

iOS 14.2 第一次生成常常丢图,第二次或第三次才正常。权宜之计是连调 2~3 次取最后一次;issue 里仍有机型不行。低端安卓会失败,多半是文字问题(旧笔记,不完全确定):

20210425083933

用在 PC 或 Android 时,清晰度不必改源码,作者提供了参数:

清晰度问题:看到很多人说要去改源码,其实不用的,作者是提供了参数了
// 通过 scale 放大设备的 DPR 倍数
let elm = document.getElementById('my-node')
const scale = window.devicePixelRatio
this.loader.open()
await this.$nextTick()
domtoimage
    .toBlob(elm, {
         quantity: 1,
         width: elm.offsetWidth * scale, // 画布放大
         height: elm.offsetHeight * scale,
        style: {
            transform: `scale(${scale})`, // 元素放大
            transformOrigin: '0 0',
        },
    })
    .then(this.upload) // 处理结果
    .catch(function (error) {
        this.loader.close()
        console.error('oops, something went wrong!', error)
    })

四、前端 Canvas 手绘

画布与图片

let canvasBox = document.createElement('canvas')
let ctx = canvasBox.getContext(
                    '2d'
                ) as CanvasRenderingContext2D
let bgImag = ''
ctx.drawImage(bgImg, 0, 0)

图片跨域:

解决办法 :
1)使用 crossOrigin 属性。
解决 canvas 图片 getImageData、toDataURL 跨域问题
2)图片本身也需要允许跨域
3)设置 useCORS:true,原理相同,但使用以上跨域方法,若同时设置为 allowTaint:true ,仍然会认为画布已被污染而不可用;

文字

换行的核心:按每行最大宽度把字符串拆开再画。参考:https://www.zhangxinxu.com/wordpress/2018/02/canvas-text-break-line-letter-spacing-vertical/

核心:计算所有文字,根据每行可显示的最大宽度,来拆分成每行渲染

字体:只用默认或少量定制字体时,可用 Fontmin 裁切后 @font-face。涉及版权,必须确认你们有该字体授权。

@font-face {
    font-family: "DIN Condensed";
    // prettier-ignore
    src: url("~@/assets/fonts/DIN-Condensed-Bold.woff") format("woff"), url("~@/assets/fonts/DIN-Condensed-Bold.ttf") format("ttf");
}

@font-face {
    font-family: "PingFang SC Bold";
    // prettier-ignore
    src: url("~@/assets/fonts/PingFang-SC-Bold.ttf") format("ttf"), url("~@/assets/fonts/PingFang-SC-Bold.woff") format("woff");
}
ctx.fillText('20px PingFang SC Bold', x, y)
ctx.fillText('20px', x, y)

必须完整某字体、且文案动态变化时,让接口出 SVG,再画进 Canvas。注意直接 DOM 转 SVG 在安卓上也有失败:

svg to img
直接 domtoSvg 也有安卓失败的问题
let svg = 接口获取svg(参考年度账单)
let svgBase64: string =
    'data:image/svg+xml;base64,' +
    window.btoa(
        unescape(encodeURIComponent(svg))
    )

await this.loadImg(svgBase64).then(
    img => {
        ctxC.drawImage(
            img,
            ~~(20 * this.scale),
            ~~(186 * this.scale)
        )
    }
)

手机端比例

尽量用整数,按 devicePixelRatio 和设计稿宽度算 scale,避免糊:

// 尽量使用整数

// devicePixelRatio
protected imgQuality = window.devicePixelRatio

// 和设计稿的比例 设计稿宽度是640
get scale() {
    return (document.body.clientWidth * this.imgQuality) / 640
}

// 画布宽高,避免生成的图片模糊情况出现
canvasBox.width = Math.ceil(
    document.body.clientWidth * this.imgQuality
)
canvasBox.height = Math.ceil(980 * this.scale)

// 二维码的宽度
get qrCodeSize() {
    return ~~(155 * this.scale) // rpx * scale
}

线、圆、圆角矩形

// 画布和设计稿750的比例
scale =  (document.body.clientWidth * this.imgQuality) / 750
// 线:
/**
 * 画线
 */
export function drawLine(
    ctx: CanvasRenderingContext2D,
    data: {
        x: number
        y: number
        width: number
        height: number
        strokeStyle: string
    },
    scale: number
) {
    let x = data.x * scale
    let y = data.y * scale
    let dx = (data.x + data.width) * scale
    let dy = data.y * scale

    // 开始一个新的绘制路径
    ctx.beginPath()
    // 定义直线的起点坐标为(10,10)
    ctx.moveTo(x, y)
    // 定义直线的终点坐标为(50,10)
    ctx.lineTo(dx, dy)
    // 颜色
    ctx.strokeStyle = data.strokeStyle
    // 沿着坐标点顺序的路径绘制直线
    ctx.stroke()
    // 关闭当前的绘制路径
    ctx.closePath()
}

// 圆:
/**
 * 画圆
 */
export function drawCircle(
    ctx: CanvasRenderingContext2D,
    data: {
        x: number // 圆心
        y: number
        size: number // 半径
        fillStyle: string
    },
    scale: number
) {
    ctx.save()
    ctx.beginPath()
    ctx.arc(
        ~~(data.x * scale),
        ~~(data.y * scale),
        ~~(data.size * scale),
        0,
        ~~(data.size * scale * Math.PI)
    )
    ctx.fillStyle = data.fillStyle
    ctx.fill()
    ctx.restore()
}

// 矩形:
/**
 * 画圆角矩形
 */
export function drawRoundRect(
    ctx: CanvasRenderingContext2D,
    data: {
        x: number
        y: number
        width: number
        height: number
        radius: number
        fillStyle: string
    },
    scale: number
) {
    let width = ~~(data.width * scale)
    let height = ~~(data.height * scale)
    let radius = ~~(data.radius * scale)
    let fillStyle = data.fillStyle
    ctx.save()
    ctx.translate(~~(data.x * scale), ~~(data.y * scale))
    ctx.beginPath()
    // 从右下角顺时针绘制,弧度从0到1/2PI
    ctx.arc(width - radius, height - radius, radius, 0, Math.PI / 2)

    // 矩形下边线
    ctx.lineTo(radius, height)

    // 左下角圆弧,弧度从1/2PI到PI
    ctx.arc(radius, height - radius, radius, Math.PI / 2, Math.PI)

    // 矩形左边线
    ctx.lineTo(0, radius)

    // 左上角圆弧,弧度从PI到3/2PI
    ctx.arc(radius, radius, radius, Math.PI, (Math.PI * 3) / 2)

    // 上边线
    ctx.lineTo(width - radius, 0)

    // 右上角圆弧
    ctx.arc(width - radius, radius, radius, (Math.PI * 3) / 2, Math.PI * 2)

    // 右边线
    ctx.lineTo(width, height - radius)
    ctx.closePath()
    ctx.fillStyle = fillStyle
    ctx.fill()
    ctx.restore()
}

画整圆时 arc 的扫角应是 2 * Math.PI。上面 drawCircle 写成了 size * scale * Math.PI,半径一大圆弧就不闭合,需要对齐设计稿再改。

导出与保存

canvasBox.toBlob(async data => {
    if (data) {
        //上传到7niu
        this.url = await uploadFile(data)
    }
})

PC 下载:

import { saveAs } from 'file-saver'
saveAs(blob, filename)

H5:

长按保存
包壳的 H5 是可以实现直接下载的,但为了交互一致性,还是用了长按

五、接口生成与跨域

把元素和位置传给后端,接口直接回图。优点是前端几乎不用管兼容;缺点是字数、字体种类一多,服务器压力就大,元素越多越慢。后端同样要算换行高度(同一字体中英文宽度不同)和后续元素相对位置。

跨域图导不下来时,把下面这段看完。来源:https://segmentfault.com/q/10…

20210425084029

六、怎么选

无特殊情况,PC 用 dom-to-image 即可。H5 这边走过的路:

  1. 第三方库(兼容问题,太久远只记得方案被驳回)
  2. 接口绘制(服务器过载,太慢)
  3. 前端 Canvas(部分元素由接口画成图或 SVG 再贴上,兼容还行,但仍慢)
  4. 再回到 html2canvas(测过若干环境,项目当时未上线,还需观察)

当时看起来 html2canvas 更稳,但有些效果画不出来,要在还原度和性能之间取舍。不少 App 出海报更快,可能是原生能力,H5 本身有限制。

方案 适合 优点 短板
html2canvas H5 多端 兼容面相对宽 伪类 / dash / text-shadow 不支持
dom-to-image PC Chrome 还原度高 不支持 Safari,停更;iOS 14.2 易丢图
Canvas 手绘 版式固定、要控清晰度 跨域、字体、比例都可自己管 开发量大,文字换行和版权烦
接口出图 前端环境太乱 前端无兼容包袱 字多、元素多就慢,服务器压力大

七、参考

  1. 【JS】节点截图的最终解决方案 dom-to-image 与 html2canvas:https://blog.csdn.net/Mcky_Lo…
  2. 移动端 H5 页面截图:https://cloud.tencent.com/dev…
  3. 把 DOM 节点生成 base64 图片:https://www.jianshu.com/p/c5c…
  4. 更优雅地基于 canvas 在前端画海报:https://juejin.cn/post/684490…
  5. canvas 文本绘制自动换行、字间距、竖排等实现 https://www.zhangxinxu.com/wo…
  6. 图片跨域问题:https://segmentfault.com/q/1010000008648867
  7. https://mp.weixin.qq.com/s/UB9cCPPf7k_vVvlrcbka-g

一句话总结:海报没有银弹:PC 优先 dom-to-image,H5 更常落到 html2canvas 或 Canvas;跨域、字体、DPR 才是真正耗时的地方。

转载请注明来源:生成前端海报的 N 种方案和优劣
本文链接地址:https://ai.zhousir.top/?p=3241
回复 取消