Satori (Vercel的)是一个可以把HTML+CSS生成SVG的一个库。

通常提到把HTML转为图片,都会想到 html2canvashtml-to-image这类的库,但这类库需要借助浏览器环境,比如各种卡片类网站的导出功能(css特性支持有限)。但如果是多端都有生成需求,或者要实现更便捷的获取方式,就得考虑放在后端去实现。

而Satori只需要接收JSX元素就可以计算得出SVG内容,不需要在前端就可以实现。重要的是,文字的字体也会保留,文字直接被解析成了path

虽然Satori不保证SVG和浏览器呈现的 HTML 100%匹配,但我觉得仅是脱离浏览器和保留了相当一部分css属性的支持,就足够产生无限的想象。

 

image

要实现的功能很简单,前端网站上有个文字转卡片的界面,支持保存主题和样式的预设到后端,然后用户在其他地方调接口就能到图片。

  • 前端页面上可以自定义一套样式,包括背景,渐变,flex布局,阴影等等一切 Satori 支持的css
  • 前端的html框架和后端jsx的框架保持一致。比如一张卡片就是套三个div,最外层负责渐变色,中间层负责半透明+磨砂效果,最内层div负责展示文字。那hono中也用jsx定义好一样的结构,并在前端维护好三个style对象,调用接口把样式存起来,比如和用户id挂钩。或者把页面结构也存起来。
  • 用户传文本过来,拿到对应的结构和样式,把文本塞进去,用Satori生成SVG,返回给用户
  • 为了防止消耗大量资源,限流一下,比如每分钟xx次

关于Hono项目的搭建、部署,我已经写过一个简易的流程了,可以自行翻阅,这部分就跳过了。

直接在项目内新建一个目录 src/imgx

初始化该子模块下的路由 src/imgx/index.tsx并在根路由下挂载

const imgx = new Hono<{ Variables: Variables }>();

imgx.post("/gen", zvalidator('json', textGenSchema), async (c) => {
  const { text } = c.req.valid('json')

  const svg = await renderSVG(c, <><div>{text}</div></>)
  c.header('Content-Type', 'image/svg+xml');
  c.header('Content-Disposition', 'attachment; filename="imgx.svg"');
  return c.body(svg)
})

因为要直接写JSX,所以直接把文件名后缀改为.tsx即可。tsx 的内容还是按正常的写法,只不过它支持JSX了,如果用到类型的话,可以在 hono/jsx 中导出 { FC, JSX }

结构参数用 zvalidator 校验一下,或者把此接口白名单去掉,需要登录后才能使用。

关于怎么存样式和HTML框架就不写了,随意怎么存都行,我这里直接存个json文件做演示。

当收到请求时并通过校验后,先去读取对应的样式,当然也有可能读不到

try {
    style = fs.readFileSync(path.resolve(process.cwd(), "style.json"));
  } catch(err) {
    c.set('errMsg', '不存在预设的样式文件, 请联系管理员处理')
    throw new HTTPException(400)
  }

然后把样式里的各种信息解析出来

  const { bgStyle, innerStyle, textStyle, imgSize } = JSON.parse(style)

再传给 Satori 处理就可以了

import { fonts } from '../common/fonts'

const svg = await satori(
    <div
    style={{ 
      ...bgStyle,
      ...textStyle
    }}
>   
<div style={{ ...innerStyle }}>
{ element }
</div>
    
</div> ,
    {
      width: imgSize.width,
      height: imgSize.height,
      fonts: fonts
    }

  )

因为我这个是文字生成图片,只要存在文字,Satori 就一定要显式的传入字体,也就是上边的fonts

而字体库,可以自己维护在服务器上,应该用到的也不是很多,Satori 支持 ttfoftwoff 这三种格式的字体。要把字体数据作为 ArrayBuffer 或 Buffer 传递。

我用的Bun运行Hono项目,所以可以这样处理:

import type { FontStyle, FontWeight } from "satori";
import path from 'path'
const YouSheBiaoTiHei = Bun.file(path.resolve(process.cwd(), "fonts", "YouSheBiaoTiHei-2.ttf"));
export const fonts: Array<{
  name: string;
  data: ArrayBuffer;
  weight: FontWeight;
  style: FontStyle;
}> = [
    {
      name: "YouSheBiaoTiHei",
      data: await YouSheBiaoTiHei.arrayBuffer(),
      weight: 500,
      style: 'normal'
    }
  ]

最后不要忘了处理header

c.header('Content-Type', 'image/svg+xml');
c.header('Content-Disposition', 'attachment; filename="imgx.zzao.club.svg"');

res.body 返回就可以了

然后可以用 hono-rate-limiter 做一下限流

import { rateLimiter } from "hono-rate-limiter";

const limiter = rateLimiter({
  windowMs: 1 * 60 * 1000, // 1分钟
  limit: 50, // Limit each IP to 50 requests per `window` (here, per 1 minutes).
  standardHeaders: "draft-6", // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header
  keyGenerator: (c) => c.req.url, // Method to generate custom identifiers for clients.
  // store: ... , // Redis, MemoryStore, etc. See below.
});

limiter 是一个中间价,可以直接在全局启用,也可以单独在某一个路由上使用

imgx.post("/gen", limiter, zvalidator('json', textGenSchema), async (c) => {
    ...
})

当来自某个ip的请求,在1分钟内超过了50次,就会直接返回HTTP错误,提示请求了太多次(too many request ... )

生成了SVG, 可以用前端通过 post 请求,并设置 responseType: 'blob' ,拿到数据,然后配合a标签直接进行下载。

const a = document.createElement('a')
const dataUrl = URL.createObjectURL(response.data)
a.href = dataUrl
a.download = 'image.svg'
a.click()

但这样就和直接在前端生成图片比,看起来没有优势了。当然也可以拿到svg再用其他canvas插件处理一下,二次编辑一下。

或者直接在后端使用 Resvg 来生成PNGResvgrust写的,所以速度比较快,内存占用比较小。

但最重要的是这是一个独立的接口,也就意味着我无需再打开某个卡片网站,再复制进文字,再点击下载。

我可以直接在自己的笔记软件里、在博客上、浏览器插件里接入接口,做到看到什么就分享什么,写出什么就分享什么的效果。

毕竟一个卡片网站有再多的主题,自己常用的其实就1-2个,而每次文字要分享的文字是不一样。

所以我觉得在变化的地方起手是比较舒适的操作。

不知道你意下如何?