[{"data":1,"prerenderedAt":1382},["ShallowReactive",2],{"page-/post/imgx/hono-satori-svg-creator":3,"surrounding-page":1373},{"id":4,"title":5,"author":6,"body":7,"date":1358,"description":1359,"extension":1360,"group":6,"lastmod":1361,"meta":1362,"navigation":198,"path":1363,"rawbody":1364,"seo":1365,"showTitle":5,"stem":1366,"tags":1367,"versions":1369,"__hash__":1372},"content/post/imgx/hono-satori-svg-creator.md","基于Hono和Satori的后端生成SVG图片简易方案",null,{"type":8,"value":9,"toc":1353},"minimark",[10,26,37,52,69,77,81,84,87,121,125,132,138,145,382,407,413,416,419,518,521,566,569,749,758,775,778,984,989,1028,1035,1042,1164,1170,1217,1220,1223,1233,1311,1314,1332,1337,1340,1343,1346,1349],[11,12,13,17,18,21,22,25],"p",{},[14,15,16],"code",{},"Satori"," （Vercel的）是一个可以把",[14,19,20],{},"HTML+CSS","生成",[14,23,24],{},"SVG","的一个库。",[11,27,28,29,32,33,36],{},"通常提到把HTML转为图片，都会想到 ",[14,30,31],{},"html2canvas"," 、",[14,34,35],{},"html-to-image","这类的库，但这类库需要借助浏览器环境，比如各种卡片类网站的导出功能（css特性支持有限）。但如果是多端都有生成需求，或者要实现更便捷的获取方式，就得考虑放在后端去实现。",[11,38,39,40,44,45,47,48,51],{},"而Satori只需要接收",[41,42,43],"strong",{},"JSX元素","就可以计算得出",[14,46,24],{},"内容，不需要在前端就可以实现。重要的是，文字的字体也会保留，文字直接被解析成了",[14,49,50],{},"path","！",[11,53,54,55,57,58,60,61,64,65,68],{},"虽然",[14,56,16],{},"不保证",[14,59,24],{},"和浏览器呈现的 ",[14,62,63],{},"HTML"," 100%匹配，但我觉得仅是脱离浏览器和保留了相当一部分",[14,66,67],{},"css","属性的支持，就足够产生无限的想象。",[11,70,71,72],{}," ",[73,74],"img",{"alt":75,"src":76},"","https://imgx.zzao.club/api/img/001/%E6%88%91%E4%B8%8D%E5%BE%97%E4%B8%8D%E5%91%8A%E8%AF%89%E4%BD%A0%E7%9A%84+deepseek-r1%E7%9A%84%E4%BD%BF%E7%94%A8%E6%8A%80%E5%B7%A7",[78,79,80],"h2",{"id":80},"功能构思",[11,82,83],{},"要实现的功能很简单，前端网站上有个文字转卡片的界面，支持保存主题和样式的预设到后端，然后用户在其他地方调接口就能到图片。",[85,86],"br",{},[88,89,90,100,110,118],"ul",{},[91,92,93,94,96,97,99],"li",{},"前端页面上可以自定义一套样式，包括背景，渐变，flex布局，阴影等等一切 ",[14,95,16],{}," 支持的",[14,98,67],{}," 。",[91,101,102,105,106,109],{},[41,103,104],{},"前端的html框架和后端jsx的框架保持一致","。比如一张卡片就是套三个div，最外层负责渐变色，中间层负责半透明+磨砂效果，最内层div负责展示文字。那hono中也用jsx定义好一样的结构，并在前端维护好三个",[14,107,108],{},"style对象","，调用接口把样式存起来，比如和用户id挂钩。或者把页面结构也存起来。",[91,111,112,113,21,115,117],{},"用户传文本过来，拿到对应的结构和样式，把文本塞进去，用",[14,114,16],{},[14,116,24],{},"，返回给用户",[91,119,120],{},"为了防止消耗大量资源，限流一下，比如每分钟xx次",[78,122,124],{"id":123},"hono中直接使用tsx","Hono中直接使用TSX",[11,126,127,128,131],{},"关于",[14,129,130],{},"Hono","项目的搭建、部署，我已经写过一个简易的流程了，可以自行翻阅，这部分就跳过了。",[11,133,134,135],{},"直接在项目内新建一个目录 ",[14,136,137],{},"src/imgx",[11,139,140,141,144],{},"初始化该子模块下的路由 ",[14,142,143],{},"src/imgx/index.tsx","并在根路由下挂载",[146,147,151],"pre",{"className":148,"code":149,"language":150,"meta":75,"style":75},"language-typescript shiki shiki-themes github-light","const imgx = new Hono\u003C{ Variables: Variables }>();\n\nimgx.post(\"/gen\", zvalidator('json', textGenSchema), async (c) => {\n  const { text } = c.req.valid('json')\n\n  const svg = await renderSVG(c, \u003C>\u003Cdiv>{text}\u003C/div>\u003C/>)\n  c.header('Content-Type', 'image/svg+xml');\n  c.header('Content-Disposition', 'attachment; filename=\"imgx.svg\"');\n  return c.body(svg)\n})\n","typescript",[14,152,153,193,200,248,279,284,320,342,361,376],{"__ignoreMap":75},[154,155,158,162,166,169,172,176,180,184,187,190],"span",{"class":156,"line":157},"line",1,[154,159,161],{"class":160},"sD7c4","const",[154,163,165],{"class":164},"sYu0t"," imgx",[154,167,168],{"class":160}," =",[154,170,171],{"class":160}," new",[154,173,175],{"class":174},"s7eDp"," Hono",[154,177,179],{"class":178},"sgsFI","\u003C{ ",[154,181,183],{"class":182},"sqxcx","Variables",[154,185,186],{"class":160},":",[154,188,189],{"class":174}," Variables",[154,191,192],{"class":178}," }>();\n",[154,194,196],{"class":156,"line":195},2,[154,197,199],{"emptyLinePlaceholder":198},true,"\n",[154,201,203,206,209,212,216,219,222,224,227,230,233,236,239,242,245],{"class":156,"line":202},3,[154,204,205],{"class":178},"imgx.",[154,207,208],{"class":174},"post",[154,210,211],{"class":178},"(",[154,213,215],{"class":214},"sYBdl","\"/gen\"",[154,217,218],{"class":178},", ",[154,220,221],{"class":174},"zvalidator",[154,223,211],{"class":178},[154,225,226],{"class":214},"'json'",[154,228,229],{"class":178},", textGenSchema), ",[154,231,232],{"class":160},"async",[154,234,235],{"class":178}," (",[154,237,238],{"class":182},"c",[154,240,241],{"class":178},") ",[154,243,244],{"class":160},"=>",[154,246,247],{"class":178}," {\n",[154,249,251,254,257,260,263,266,269,272,274,276],{"class":156,"line":250},4,[154,252,253],{"class":160},"  const",[154,255,256],{"class":178}," { ",[154,258,259],{"class":164},"text",[154,261,262],{"class":178}," } ",[154,264,265],{"class":160},"=",[154,267,268],{"class":178}," c.req.",[154,270,271],{"class":174},"valid",[154,273,211],{"class":178},[154,275,226],{"class":214},[154,277,278],{"class":178},")\n",[154,280,282],{"class":156,"line":281},5,[154,283,199],{"emptyLinePlaceholder":198},[154,285,287,289,292,294,297,300,303,306,309,312,315,318],{"class":156,"line":286},6,[154,288,253],{"class":160},[154,290,291],{"class":164}," svg",[154,293,168],{"class":160},[154,295,296],{"class":160}," await",[154,298,299],{"class":174}," renderSVG",[154,301,302],{"class":178},"(c, \u003C>\u003C",[154,304,305],{"class":174},"div",[154,307,308],{"class":178},">{text}",[154,310,311],{"class":160},"\u003C",[154,313,314],{"class":214},"/div>\u003C/",[154,316,317],{"class":160},">",[154,319,278],{"class":178},[154,321,323,326,329,331,334,336,339],{"class":156,"line":322},7,[154,324,325],{"class":178},"  c.",[154,327,328],{"class":174},"header",[154,330,211],{"class":178},[154,332,333],{"class":214},"'Content-Type'",[154,335,218],{"class":178},[154,337,338],{"class":214},"'image/svg+xml'",[154,340,341],{"class":178},");\n",[154,343,345,347,349,351,354,356,359],{"class":156,"line":344},8,[154,346,325],{"class":178},[154,348,328],{"class":174},[154,350,211],{"class":178},[154,352,353],{"class":214},"'Content-Disposition'",[154,355,218],{"class":178},[154,357,358],{"class":214},"'attachment; filename=\"imgx.svg\"'",[154,360,341],{"class":178},[154,362,364,367,370,373],{"class":156,"line":363},9,[154,365,366],{"class":160},"  return",[154,368,369],{"class":178}," c.",[154,371,372],{"class":174},"body",[154,374,375],{"class":178},"(svg)\n",[154,377,379],{"class":156,"line":378},10,[154,380,381],{"class":178},"})\n",[11,383,384,385,388,389,392,393,395,396,398,399,402,403,406],{},"因为要直接写",[14,386,387],{},"JSX","，所以直接把文件名后缀改为.",[14,390,391],{},"tsx","即可。",[14,394,391],{}," 的内容还是按正常的写法，只不过它支持",[14,397,387],{},"了，如果用到类型的话，可以在 ",[14,400,401],{},"hono/jsx"," 中导出 ",[14,404,405],{},"{ FC, JSX }","。",[11,408,409,410,412],{},"结构参数用 ",[14,411,221],{}," 校验一下，或者把此接口白名单去掉，需要登录后才能使用。",[11,414,415],{},"关于怎么存样式和HTML框架就不写了，随意怎么存都行，我这里直接存个json文件做演示。",[11,417,418],{},"当收到请求时并通过校验后，先去读取对应的样式，当然也有可能读不到",[146,420,422],{"className":148,"code":421,"language":150,"meta":75,"style":75},"try {\n    style = fs.readFileSync(path.resolve(process.cwd(), \"style.json\"));\n  } catch(err) {\n    c.set('errMsg', '不存在预设的样式文件, 请联系管理员处理')\n    throw new HTTPException(400)\n  }\n",[14,423,424,431,465,476,496,513],{"__ignoreMap":75},[154,425,426,429],{"class":156,"line":157},[154,427,428],{"class":160},"try",[154,430,247],{"class":178},[154,432,433,436,438,441,444,447,450,453,456,459,462],{"class":156,"line":195},[154,434,435],{"class":178},"    style ",[154,437,265],{"class":160},[154,439,440],{"class":178}," fs.",[154,442,443],{"class":174},"readFileSync",[154,445,446],{"class":178},"(path.",[154,448,449],{"class":174},"resolve",[154,451,452],{"class":178},"(process.",[154,454,455],{"class":174},"cwd",[154,457,458],{"class":178},"(), ",[154,460,461],{"class":214},"\"style.json\"",[154,463,464],{"class":178},"));\n",[154,466,467,470,473],{"class":156,"line":202},[154,468,469],{"class":178},"  } ",[154,471,472],{"class":160},"catch",[154,474,475],{"class":178},"(err) {\n",[154,477,478,481,484,486,489,491,494],{"class":156,"line":250},[154,479,480],{"class":178},"    c.",[154,482,483],{"class":174},"set",[154,485,211],{"class":178},[154,487,488],{"class":214},"'errMsg'",[154,490,218],{"class":178},[154,492,493],{"class":214},"'不存在预设的样式文件, 请联系管理员处理'",[154,495,278],{"class":178},[154,497,498,501,503,506,508,511],{"class":156,"line":281},[154,499,500],{"class":160},"    throw",[154,502,171],{"class":160},[154,504,505],{"class":174}," HTTPException",[154,507,211],{"class":178},[154,509,510],{"class":164},"400",[154,512,278],{"class":178},[154,514,515],{"class":156,"line":286},[154,516,517],{"class":178},"  }\n",[11,519,520],{},"然后把样式里的各种信息解析出来",[146,522,524],{"className":148,"code":523,"language":150,"meta":75,"style":75},"  const { bgStyle, innerStyle, textStyle, imgSize } = JSON.parse(style)\n",[14,525,526],{"__ignoreMap":75},[154,527,528,530,532,535,537,540,542,545,547,550,552,554,557,560,563],{"class":156,"line":157},[154,529,253],{"class":160},[154,531,256],{"class":178},[154,533,534],{"class":164},"bgStyle",[154,536,218],{"class":178},[154,538,539],{"class":164},"innerStyle",[154,541,218],{"class":178},[154,543,544],{"class":164},"textStyle",[154,546,218],{"class":178},[154,548,549],{"class":164},"imgSize",[154,551,262],{"class":178},[154,553,265],{"class":160},[154,555,556],{"class":164}," JSON",[154,558,559],{"class":178},".",[154,561,562],{"class":174},"parse",[154,564,565],{"class":178},"(style)\n",[11,567,568],{},"再传给 Satori 处理就可以了",[146,570,572],{"className":148,"code":571,"language":150,"meta":75,"style":75},"import { fonts } from '../common/fonts'\n\nconst svg = await satori(\n    \u003Cdiv\n    style={{ \n      ...bgStyle,\n      ...textStyle\n    }}\n>   \n\u003Cdiv style={{ ...innerStyle }}>\n{ element }\n\u003C/div>\n    \n\u003C/div> ,\n    {\n      width: imgSize.width,\n      height: imgSize.height,\n      fonts: fonts\n    }\n\n  )\n",[14,573,574,588,592,608,616,626,634,641,646,653,674,680,690,696,708,714,720,726,732,738,743],{"__ignoreMap":75},[154,575,576,579,582,585],{"class":156,"line":157},[154,577,578],{"class":160},"import",[154,580,581],{"class":178}," { fonts } ",[154,583,584],{"class":160},"from",[154,586,587],{"class":214}," '../common/fonts'\n",[154,589,590],{"class":156,"line":195},[154,591,199],{"emptyLinePlaceholder":198},[154,593,594,596,598,600,602,605],{"class":156,"line":202},[154,595,161],{"class":160},[154,597,291],{"class":164},[154,599,168],{"class":160},[154,601,296],{"class":160},[154,603,604],{"class":174}," satori",[154,606,607],{"class":178},"(\n",[154,609,610,613],{"class":156,"line":250},[154,611,612],{"class":160},"    \u003C",[154,614,615],{"class":178},"div\n",[154,617,618,621,623],{"class":156,"line":281},[154,619,620],{"class":178},"    style",[154,622,265],{"class":160},[154,624,625],{"class":178},"{{ \n",[154,627,628,631],{"class":156,"line":286},[154,629,630],{"class":160},"      ...",[154,632,633],{"class":178},"bgStyle,\n",[154,635,636,638],{"class":156,"line":322},[154,637,630],{"class":160},[154,639,640],{"class":178},"textStyle\n",[154,642,643],{"class":156,"line":344},[154,644,645],{"class":178},"    }}\n",[154,647,648,650],{"class":156,"line":363},[154,649,317],{"class":160},[154,651,652],{"class":178},"   \n",[154,654,655,657,660,662,665,668,671],{"class":156,"line":378},[154,656,311],{"class":160},[154,658,659],{"class":178},"div style",[154,661,265],{"class":160},[154,663,664],{"class":178},"{{ ",[154,666,667],{"class":160},"...",[154,669,670],{"class":178},"innerStyle }}",[154,672,673],{"class":160},">\n",[154,675,677],{"class":156,"line":676},11,[154,678,679],{"class":178},"{ element }\n",[154,681,683,686,688],{"class":156,"line":682},12,[154,684,685],{"class":160},"\u003C/",[154,687,305],{"class":178},[154,689,673],{"class":160},[154,691,693],{"class":156,"line":692},13,[154,694,695],{"class":178},"    \n",[154,697,699,701,703,705],{"class":156,"line":698},14,[154,700,685],{"class":160},[154,702,305],{"class":178},[154,704,317],{"class":160},[154,706,707],{"class":178}," ,\n",[154,709,711],{"class":156,"line":710},15,[154,712,713],{"class":178},"    {\n",[154,715,717],{"class":156,"line":716},16,[154,718,719],{"class":178},"      width: imgSize.width,\n",[154,721,723],{"class":156,"line":722},17,[154,724,725],{"class":178},"      height: imgSize.height,\n",[154,727,729],{"class":156,"line":728},18,[154,730,731],{"class":178},"      fonts: fonts\n",[154,733,735],{"class":156,"line":734},19,[154,736,737],{"class":178},"    }\n",[154,739,741],{"class":156,"line":740},20,[154,742,199],{"emptyLinePlaceholder":198},[154,744,746],{"class":156,"line":745},21,[154,747,748],{"class":178},"  )\n",[11,750,751,752,754,755],{},"因为我这个是文字生成图片，只要存在文字，",[14,753,16],{}," 就一定要显式的传入字体，也就是上边的",[14,756,757],{},"fonts",[11,759,760,761,763,764,32,767,770,771,774],{},"而字体库，可以自己维护在服务器上，应该用到的也不是很多，",[14,762,16],{}," 支持 ",[14,765,766],{},"ttf",[14,768,769],{},"oft"," 、 ",[14,772,773],{},"woff"," 这三种格式的字体。要把字体数据作为 ArrayBuffer 或 Buffer 传递。",[11,776,777],{},"我用的Bun运行Hono项目，所以可以这样处理：",[146,779,781],{"className":148,"code":780,"language":150,"meta":75,"style":75},"import type { FontStyle, FontWeight } from \"satori\";\nimport path from 'path'\nconst YouSheBiaoTiHei = Bun.file(path.resolve(process.cwd(), \"fonts\", \"YouSheBiaoTiHei-2.ttf\"));\nexport const fonts: Array\u003C{\n  name: string;\n  data: ArrayBuffer;\n  weight: FontWeight;\n  style: FontStyle;\n}> = [\n    {\n      name: \"YouSheBiaoTiHei\",\n      data: await YouSheBiaoTiHei.arrayBuffer(),\n      weight: 500,\n      style: 'normal'\n    }\n  ]\n",[14,782,783,801,813,848,867,879,891,903,915,925,929,940,957,967,975,979],{"__ignoreMap":75},[154,784,785,787,790,793,795,798],{"class":156,"line":157},[154,786,578],{"class":160},[154,788,789],{"class":160}," type",[154,791,792],{"class":178}," { FontStyle, FontWeight } ",[154,794,584],{"class":160},[154,796,797],{"class":214}," \"satori\"",[154,799,800],{"class":178},";\n",[154,802,803,805,808,810],{"class":156,"line":195},[154,804,578],{"class":160},[154,806,807],{"class":178}," path ",[154,809,584],{"class":160},[154,811,812],{"class":214}," 'path'\n",[154,814,815,817,820,822,825,828,830,832,834,836,838,841,843,846],{"class":156,"line":202},[154,816,161],{"class":160},[154,818,819],{"class":164}," YouSheBiaoTiHei",[154,821,168],{"class":160},[154,823,824],{"class":178}," Bun.",[154,826,827],{"class":174},"file",[154,829,446],{"class":178},[154,831,449],{"class":174},[154,833,452],{"class":178},[154,835,455],{"class":174},[154,837,458],{"class":178},[154,839,840],{"class":214},"\"fonts\"",[154,842,218],{"class":178},[154,844,845],{"class":214},"\"YouSheBiaoTiHei-2.ttf\"",[154,847,464],{"class":178},[154,849,850,853,856,859,861,864],{"class":156,"line":250},[154,851,852],{"class":160},"export",[154,854,855],{"class":160}," const",[154,857,858],{"class":164}," fonts",[154,860,186],{"class":160},[154,862,863],{"class":174}," Array",[154,865,866],{"class":178},"\u003C{\n",[154,868,869,872,874,877],{"class":156,"line":281},[154,870,871],{"class":182},"  name",[154,873,186],{"class":160},[154,875,876],{"class":164}," string",[154,878,800],{"class":178},[154,880,881,884,886,889],{"class":156,"line":286},[154,882,883],{"class":182},"  data",[154,885,186],{"class":160},[154,887,888],{"class":174}," ArrayBuffer",[154,890,800],{"class":178},[154,892,893,896,898,901],{"class":156,"line":322},[154,894,895],{"class":182},"  weight",[154,897,186],{"class":160},[154,899,900],{"class":174}," FontWeight",[154,902,800],{"class":178},[154,904,905,908,910,913],{"class":156,"line":344},[154,906,907],{"class":182},"  style",[154,909,186],{"class":160},[154,911,912],{"class":174}," FontStyle",[154,914,800],{"class":178},[154,916,917,920,922],{"class":156,"line":363},[154,918,919],{"class":178},"}> ",[154,921,265],{"class":160},[154,923,924],{"class":178}," [\n",[154,926,927],{"class":156,"line":378},[154,928,713],{"class":178},[154,930,931,934,937],{"class":156,"line":676},[154,932,933],{"class":178},"      name: ",[154,935,936],{"class":214},"\"YouSheBiaoTiHei\"",[154,938,939],{"class":178},",\n",[154,941,942,945,948,951,954],{"class":156,"line":682},[154,943,944],{"class":178},"      data: ",[154,946,947],{"class":160},"await",[154,949,950],{"class":178}," YouSheBiaoTiHei.",[154,952,953],{"class":174},"arrayBuffer",[154,955,956],{"class":178},"(),\n",[154,958,959,962,965],{"class":156,"line":692},[154,960,961],{"class":178},"      weight: ",[154,963,964],{"class":164},"500",[154,966,939],{"class":178},[154,968,969,972],{"class":156,"line":698},[154,970,971],{"class":178},"      style: ",[154,973,974],{"class":214},"'normal'\n",[154,976,977],{"class":156,"line":710},[154,978,737],{"class":178},[154,980,981],{"class":156,"line":716},[154,982,983],{"class":178},"  ]\n",[11,985,986,987],{},"最后不要忘了处理",[14,988,328],{},[146,990,992],{"className":148,"code":991,"language":150,"meta":75,"style":75},"c.header('Content-Type', 'image/svg+xml');\nc.header('Content-Disposition', 'attachment; filename=\"imgx.zzao.club.svg\"');\n",[14,993,994,1011],{"__ignoreMap":75},[154,995,996,999,1001,1003,1005,1007,1009],{"class":156,"line":157},[154,997,998],{"class":178},"c.",[154,1000,328],{"class":174},[154,1002,211],{"class":178},[154,1004,333],{"class":214},[154,1006,218],{"class":178},[154,1008,338],{"class":214},[154,1010,341],{"class":178},[154,1012,1013,1015,1017,1019,1021,1023,1026],{"class":156,"line":195},[154,1014,998],{"class":178},[154,1016,328],{"class":174},[154,1018,211],{"class":178},[154,1020,353],{"class":214},[154,1022,218],{"class":178},[154,1024,1025],{"class":214},"'attachment; filename=\"imgx.zzao.club.svg\"'",[154,1027,341],{"class":178},[11,1029,1030,1031,1034],{},"用 ",[14,1032,1033],{},"res.body"," 返回就可以了",[11,1036,1037,1038,1041],{},"然后可以用 ",[14,1039,1040],{},"hono-rate-limiter"," 做一下限流",[146,1043,1045],{"className":148,"code":1044,"language":150,"meta":75,"style":75},"import { rateLimiter } from \"hono-rate-limiter\";\n\nconst limiter = rateLimiter({\n  windowMs: 1 * 60 * 1000, // 1分钟\n  limit: 50, // Limit each IP to 50 requests per `window` (here, per 1 minutes).\n  standardHeaders: \"draft-6\", // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header\n  keyGenerator: (c) => c.req.url, // Method to generate custom identifiers for clients.\n  // store: ... , // Redis, MemoryStore, etc. See below.\n});\n\n",[14,1046,1047,1061,1065,1080,1105,1118,1131,1151,1159],{"__ignoreMap":75},[154,1048,1049,1051,1054,1056,1059],{"class":156,"line":157},[154,1050,578],{"class":160},[154,1052,1053],{"class":178}," { rateLimiter } ",[154,1055,584],{"class":160},[154,1057,1058],{"class":214}," \"hono-rate-limiter\"",[154,1060,800],{"class":178},[154,1062,1063],{"class":156,"line":195},[154,1064,199],{"emptyLinePlaceholder":198},[154,1066,1067,1069,1072,1074,1077],{"class":156,"line":202},[154,1068,161],{"class":160},[154,1070,1071],{"class":164}," limiter",[154,1073,168],{"class":160},[154,1075,1076],{"class":174}," rateLimiter",[154,1078,1079],{"class":178},"({\n",[154,1081,1082,1085,1088,1091,1094,1096,1099,1101],{"class":156,"line":250},[154,1083,1084],{"class":178},"  windowMs: ",[154,1086,1087],{"class":164},"1",[154,1089,1090],{"class":160}," *",[154,1092,1093],{"class":164}," 60",[154,1095,1090],{"class":160},[154,1097,1098],{"class":164}," 1000",[154,1100,218],{"class":178},[154,1102,1104],{"class":1103},"sAwPA","// 1分钟\n",[154,1106,1107,1110,1113,1115],{"class":156,"line":281},[154,1108,1109],{"class":178},"  limit: ",[154,1111,1112],{"class":164},"50",[154,1114,218],{"class":178},[154,1116,1117],{"class":1103},"// Limit each IP to 50 requests per `window` (here, per 1 minutes).\n",[154,1119,1120,1123,1126,1128],{"class":156,"line":286},[154,1121,1122],{"class":178},"  standardHeaders: ",[154,1124,1125],{"class":214},"\"draft-6\"",[154,1127,218],{"class":178},[154,1129,1130],{"class":1103},"// draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header\n",[154,1132,1133,1136,1139,1141,1143,1145,1148],{"class":156,"line":322},[154,1134,1135],{"class":174},"  keyGenerator",[154,1137,1138],{"class":178},": (",[154,1140,238],{"class":182},[154,1142,241],{"class":178},[154,1144,244],{"class":160},[154,1146,1147],{"class":178}," c.req.url, ",[154,1149,1150],{"class":1103},"// Method to generate custom identifiers for clients.\n",[154,1152,1153,1156],{"class":156,"line":344},[154,1154,1155],{"class":1103},"  // store: ... ,",[154,1157,1158],{"class":1103}," // Redis, MemoryStore, etc. See below.\n",[154,1160,1161],{"class":156,"line":363},[154,1162,1163],{"class":178},"});\n",[11,1165,1166,1169],{},[14,1167,1168],{},"limiter"," 是一个中间价，可以直接在全局启用，也可以单独在某一个路由上使用",[146,1171,1173],{"className":148,"code":1172,"language":150,"meta":75,"style":75},"imgx.post(\"/gen\", limiter, zvalidator('json', textGenSchema), async (c) => {\n    ...\n})\n",[14,1174,1175,1208,1213],{"__ignoreMap":75},[154,1176,1177,1179,1181,1183,1185,1188,1190,1192,1194,1196,1198,1200,1202,1204,1206],{"class":156,"line":157},[154,1178,205],{"class":178},[154,1180,208],{"class":174},[154,1182,211],{"class":178},[154,1184,215],{"class":214},[154,1186,1187],{"class":178},", limiter, ",[154,1189,221],{"class":174},[154,1191,211],{"class":178},[154,1193,226],{"class":214},[154,1195,229],{"class":178},[154,1197,232],{"class":160},[154,1199,235],{"class":178},[154,1201,238],{"class":182},[154,1203,241],{"class":178},[154,1205,244],{"class":160},[154,1207,247],{"class":178},[154,1209,1210],{"class":156,"line":195},[154,1211,1212],{"class":160},"    ...\n",[154,1214,1215],{"class":156,"line":202},[154,1216,381],{"class":178},[11,1218,1219],{},"当来自某个ip的请求，在1分钟内超过了50次，就会直接返回HTTP错误，提示请求了太多次（too many request ... ）",[78,1221,1222],{"id":1222},"进一步处理",[11,1224,1225,1226,1228,1229,1232],{},"生成了SVG， 可以用前端通过 ",[14,1227,208],{}," 请求，并设置 ",[14,1230,1231],{},"responseType: 'blob'"," ，拿到数据，然后配合a标签直接进行下载。",[146,1234,1236],{"className":148,"code":1235,"language":150,"meta":75,"style":75},"const a = document.createElement('a')\nconst dataUrl = URL.createObjectURL(response.data)\na.href = dataUrl\na.download = 'image.svg'\na.click()\n",[14,1237,1238,1260,1280,1290,1300],{"__ignoreMap":75},[154,1239,1240,1242,1245,1247,1250,1253,1255,1258],{"class":156,"line":157},[154,1241,161],{"class":160},[154,1243,1244],{"class":164}," a",[154,1246,168],{"class":160},[154,1248,1249],{"class":178}," document.",[154,1251,1252],{"class":174},"createElement",[154,1254,211],{"class":178},[154,1256,1257],{"class":214},"'a'",[154,1259,278],{"class":178},[154,1261,1262,1264,1267,1269,1272,1274,1277],{"class":156,"line":195},[154,1263,161],{"class":160},[154,1265,1266],{"class":164}," dataUrl",[154,1268,168],{"class":160},[154,1270,1271],{"class":164}," URL",[154,1273,559],{"class":178},[154,1275,1276],{"class":174},"createObjectURL",[154,1278,1279],{"class":178},"(response.data)\n",[154,1281,1282,1285,1287],{"class":156,"line":202},[154,1283,1284],{"class":178},"a.href ",[154,1286,265],{"class":160},[154,1288,1289],{"class":178}," dataUrl\n",[154,1291,1292,1295,1297],{"class":156,"line":250},[154,1293,1294],{"class":178},"a.download ",[154,1296,265],{"class":160},[154,1298,1299],{"class":214}," 'image.svg'\n",[154,1301,1302,1305,1308],{"class":156,"line":281},[154,1303,1304],{"class":178},"a.",[154,1306,1307],{"class":174},"click",[154,1309,1310],{"class":178},"()\n",[11,1312,1313],{},"但这样就和直接在前端生成图片比，看起来没有优势了。当然也可以拿到svg再用其他canvas插件处理一下，二次编辑一下。",[11,1315,1316,1317,1320,1321,1324,1325,1327,1328,1331],{},"或者直接在后端使用 ",[14,1318,1319],{},"Resvg"," 来生成",[14,1322,1323],{},"PNG","，",[14,1326,1319],{},"是",[14,1329,1330],{},"rust","写的，所以速度比较快，内存占用比较小。",[11,1333,1334],{},[41,1335,1336],{},"但最重要的是这是一个独立的接口，也就意味着我无需再打开某个卡片网站，再复制进文字，再点击下载。",[11,1338,1339],{},"我可以直接在自己的笔记软件里、在博客上、浏览器插件里接入接口，做到看到什么就分享什么，写出什么就分享什么的效果。",[11,1341,1342],{},"毕竟一个卡片网站有再多的主题，自己常用的其实就1-2个，而每次文字要分享的文字是不一样。",[11,1344,1345],{},"所以我觉得在变化的地方起手是比较舒适的操作。",[11,1347,1348],{},"不知道你意下如何？",[1350,1351,1352],"style",{},"html pre.shiki code .sD7c4, html code.shiki .sD7c4{--shiki-default:#D73A49}html pre.shiki code .sYu0t, html code.shiki .sYu0t{--shiki-default:#005CC5}html pre.shiki code .s7eDp, html code.shiki .s7eDp{--shiki-default:#6F42C1}html pre.shiki code .sgsFI, html code.shiki .sgsFI{--shiki-default:#24292E}html pre.shiki code .sqxcx, html code.shiki .sqxcx{--shiki-default:#E36209}html pre.shiki code .sYBdl, html code.shiki .sYBdl{--shiki-default:#032F62}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":75,"searchDepth":195,"depth":195,"links":1354},[1355,1356,1357],{"id":80,"depth":195,"text":80},{"id":123,"depth":195,"text":124},{"id":1222,"depth":195,"text":1222},"2024-11-07T00:00:00.000Z","Satori （Vercel的）是一个可以把HTML+CSS生成SVG的一个库。","md","2025-08-19T00:00:00.000Z",{},"/post/imgx/hono-satori-svg-creator","---\ntitle: 基于Hono和Satori的后端生成SVG图片简易方案\ndate: 2024-11-07\nlastmod: 2025-08-19\nversions: [\"hono@4.5.11\", \"satori@0.11.2\"]\ntags: [\"Hono\", \"IMGX\"]\nshowTitle: 基于Hono和Satori的后端生成SVG图片简易方案\n---\n`Satori` （Vercel的）是一个可以把`HTML+CSS`生成`SVG`的一个库。\n\n通常提到把HTML转为图片，都会想到 `html2canvas` 、`html-to-image`这类的库，但这类库需要借助浏览器环境，比如各种卡片类网站的导出功能（css特性支持有限）。但如果是多端都有生成需求，或者要实现更便捷的获取方式，就得考虑放在后端去实现。\n\n而Satori只需要接收**JSX元素**就可以计算得出`SVG`内容，不需要在前端就可以实现。重要的是，文字的字体也会保留，文字直接被解析成了`path`！\n\n虽然`Satori`不保证`SVG`和浏览器呈现的 `HTML` 100%匹配，但我觉得仅是脱离浏览器和保留了相当一部分`css`属性的支持，就足够产生无限的想象。\n\n ![](https://imgx.zzao.club/api/img/001/%E6%88%91%E4%B8%8D%E5%BE%97%E4%B8%8D%E5%91%8A%E8%AF%89%E4%BD%A0%E7%9A%84+deepseek-r1%E7%9A%84%E4%BD%BF%E7%94%A8%E6%8A%80%E5%B7%A7)\n\n## 功能构思\n\n要实现的功能很简单，前端网站上有个文字转卡片的界面，支持保存主题和样式的预设到后端，然后用户在其他地方调接口就能到图片。\n\n\u003Cbr />\n\n* 前端页面上可以自定义一套样式，包括背景，渐变，flex布局，阴影等等一切 `Satori` 支持的`css` 。\n\n* **前端的html框架和后端jsx的框架保持一致**。比如一张卡片就是套三个div，最外层负责渐变色，中间层负责半透明+磨砂效果，最内层div负责展示文字。那hono中也用jsx定义好一样的结构，并在前端维护好三个`style对象`，调用接口把样式存起来，比如和用户id挂钩。或者把页面结构也存起来。\n\n* 用户传文本过来，拿到对应的结构和样式，把文本塞进去，用`Satori`生成`SVG`，返回给用户\n\n* 为了防止消耗大量资源，限流一下，比如每分钟xx次\n\n## Hono中直接使用TSX\n\n关于`Hono`项目的搭建、部署，我已经写过一个简易的流程了，可以自行翻阅，这部分就跳过了。\n\n直接在项目内新建一个目录 `src/imgx`\n\n初始化该子模块下的路由 `src/imgx/index.tsx`并在根路由下挂载\n\n```typescript\nconst imgx = new Hono\u003C{ Variables: Variables }>();\n\nimgx.post(\"/gen\", zvalidator('json', textGenSchema), async (c) => {\n  const { text } = c.req.valid('json')\n\n  const svg = await renderSVG(c, \u003C>\u003Cdiv>{text}\u003C/div>\u003C/>)\n  c.header('Content-Type', 'image/svg+xml');\n  c.header('Content-Disposition', 'attachment; filename=\"imgx.svg\"');\n  return c.body(svg)\n})\n```\n\n因为要直接写`JSX`，所以直接把文件名后缀改为.`tsx`即可。`tsx` 的内容还是按正常的写法，只不过它支持`JSX`了，如果用到类型的话，可以在 `hono/jsx` 中导出 `{ FC, JSX }`。\n\n结构参数用 `zvalidator` 校验一下，或者把此接口白名单去掉，需要登录后才能使用。\n\n关于怎么存样式和HTML框架就不写了，随意怎么存都行，我这里直接存个json文件做演示。\n\n当收到请求时并通过校验后，先去读取对应的样式，当然也有可能读不到\n\n```typescript\ntry {\n    style = fs.readFileSync(path.resolve(process.cwd(), \"style.json\"));\n  } catch(err) {\n    c.set('errMsg', '不存在预设的样式文件, 请联系管理员处理')\n    throw new HTTPException(400)\n  }\n```\n\n然后把样式里的各种信息解析出来\n\n```typescript\n  const { bgStyle, innerStyle, textStyle, imgSize } = JSON.parse(style)\n```\n\n再传给 Satori 处理就可以了\n\n```typescript\nimport { fonts } from '../common/fonts'\n\nconst svg = await satori(\n    \u003Cdiv\n    style={{ \n      ...bgStyle,\n      ...textStyle\n    }}\n>   \n\u003Cdiv style={{ ...innerStyle }}>\n{ element }\n\u003C/div>\n    \n\u003C/div> ,\n    {\n      width: imgSize.width,\n      height: imgSize.height,\n      fonts: fonts\n    }\n\n  )\n```\n\n因为我这个是文字生成图片，只要存在文字，`Satori` 就一定要显式的传入字体，也就是上边的`fonts`\n\n而字体库，可以自己维护在服务器上，应该用到的也不是很多，`Satori` 支持 `ttf` 、`oft` 、 `woff` 这三种格式的字体。要把字体数据作为 ArrayBuffer 或 Buffer 传递。\n\n我用的Bun运行Hono项目，所以可以这样处理：\n\n```typescript\nimport type { FontStyle, FontWeight } from \"satori\";\nimport path from 'path'\nconst YouSheBiaoTiHei = Bun.file(path.resolve(process.cwd(), \"fonts\", \"YouSheBiaoTiHei-2.ttf\"));\nexport const fonts: Array\u003C{\n  name: string;\n  data: ArrayBuffer;\n  weight: FontWeight;\n  style: FontStyle;\n}> = [\n    {\n      name: \"YouSheBiaoTiHei\",\n      data: await YouSheBiaoTiHei.arrayBuffer(),\n      weight: 500,\n      style: 'normal'\n    }\n  ]\n```\n\n最后不要忘了处理`header`\n\n```typescript\nc.header('Content-Type', 'image/svg+xml');\nc.header('Content-Disposition', 'attachment; filename=\"imgx.zzao.club.svg\"');\n```\n\n用 `res.body` 返回就可以了\n\n然后可以用 `hono-rate-limiter` 做一下限流\n\n```typescript\nimport { rateLimiter } from \"hono-rate-limiter\";\n\nconst limiter = rateLimiter({\n  windowMs: 1 * 60 * 1000, // 1分钟\n  limit: 50, // Limit each IP to 50 requests per `window` (here, per 1 minutes).\n  standardHeaders: \"draft-6\", // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header\n  keyGenerator: (c) => c.req.url, // Method to generate custom identifiers for clients.\n  // store: ... , // Redis, MemoryStore, etc. See below.\n});\n\n```\n\n`limiter` 是一个中间价，可以直接在全局启用，也可以单独在某一个路由上使用\n\n```typescript\nimgx.post(\"/gen\", limiter, zvalidator('json', textGenSchema), async (c) => {\n\t...\n})\n```\n\n当来自某个ip的请求，在1分钟内超过了50次，就会直接返回HTTP错误，提示请求了太多次（too many request ... ）\n\n## 进一步处理\n\n生成了SVG， 可以用前端通过 `post` 请求，并设置 `responseType: 'blob'` ，拿到数据，然后配合a标签直接进行下载。\n\n```typescript\nconst a = document.createElement('a')\nconst dataUrl = URL.createObjectURL(response.data)\na.href = dataUrl\na.download = 'image.svg'\na.click()\n```\n\n但这样就和直接在前端生成图片比，看起来没有优势了。当然也可以拿到svg再用其他canvas插件处理一下，二次编辑一下。\n\n或者直接在后端使用 `Resvg` 来生成`PNG`，`Resvg`是`rust`写的，所以速度比较快，内存占用比较小。\n\n**但最重要的是这是一个独立的接口，也就意味着我无需再打开某个卡片网站，再复制进文字，再点击下载。**\n\n我可以直接在自己的笔记软件里、在博客上、浏览器插件里接入接口，做到看到什么就分享什么，写出什么就分享什么的效果。\n\n毕竟一个卡片网站有再多的主题，自己常用的其实就1-2个，而每次文字要分享的文字是不一样。\n\n所以我觉得在变化的地方起手是比较舒适的操作。\n\n不知道你意下如何？\n",{"title":5,"description":1359},"post/imgx/hono-satori-svg-creator",[130,1368],"IMGX",[1370,1371],"hono@4.5.11","satori@0.11.2","CoqrdQFo0fT_fb2IPIKSEhJl1jYdXEEG-nhz-Dw8yuM",[1374,1378],{"title":1375,"path":1376,"stem":1377},"OpenClaw 安装入门（Windows）","/post/zzao/openclaw/openclaw-install-windows","post/zzao/openclaw/openclaw-install-windows",{"title":1379,"path":1380,"stem":1381},"假设你是AI，你的Skill应该是什么样的","/post/zzao/ai-skill-structure","post/zzao/ai-skill-structure",1779005086600]