[개발삽질] satori

zero86
41 min readMar 14, 2024

--

환경(npx envinfo)

  System:
OS: Windows 11 10.0.22631
CPU: (16) x64 AMD Ryzen 7 7700 8-Core Processor
Memory: 18.46 GB / 31.21 GB
Binaries:
Node: 18.18.0 - C:\Program Files\nodejs\node.EXE
Yarn: 1.22.21 - C:\Program Files\nodejs\yarn.CMD
npm: 10.2.1 - C:\Program Files\nodejs\npm.CMD
pnpm: 8.10.5 - C:\Program Files\nodejs\pnpm.CMD
Managers:
pip3: 23.2.1 - C:\Python312\Scripts\pip3.EXE
Utilities:
Git: 2.42.0.
Curl: 8.4.0 - C:\Windows\system32\curl.EXE
Virtualization:
Docker: 24.0.6 - C:\Program Files\Docker\Docker\resources\bin\docker.EXE
IDEs:
VSCode: 1.87.0 - C:\Users\rebch86\AppData\Local\Programs\Microsoft VS Code\bin\code.CMD
Languages:
Bash: 5.1.16 - C:\Windows\system32\bash.EXE
Java: 21.0.2
Python: 3.12.0
Browsers:
Edge: Chromium (122.0.2365.80)
Internet Explorer: 11.0.22621.1

이틀전 지인에게 연락이와서, og 이미지 동적으로 생성하는거 해보셨나라는 문의가 왔었다. 그것이 무엇인고 하니? 인프랩 아티클을 하나 공유를 해주셨다.

개인적으로 갑자기 흥미가 생겨서, Next.js 프로젝트를 생성하고 한번 실습을 진행해보기로 하였다.

1. next/og 패키지에서 제공해주는 ImageResponse 활용하기

import {ImageResponse} from 'next/og'
import fs from 'fs';
import path from 'path';

export async function GET(request: Request) {

const notoSansFontBuffer = fs.readFileSync(path.join(process.cwd(), 'public', 'fonts', 'NotoSansKR-SemiBold.ttf'));
const notoColorEmojiFontBuffer = fs.readFileSync(path.join(process.cwd(), 'public', 'fonts', 'NotoColorEmoji-Regular.ttf'));

const targetJsx = (<div
style={{
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
backgroundImage: 'linear-gradient(to bottom, #dbf4ff, #fff1f1)',
fontSize: 60,
letterSpacing: -2,
fontWeight: 700,
textAlign: 'center',
}}
>
<svg
height={80}
viewBox="0 0 75 65"
fill="black"
style={{margin: '0 75px'}}
>
<path d="M37.59.25l36.95 64H.64l36.95-64z"></path>
</svg>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))',
backgroundClip: 'text',
color: 'transparent',
}}
>
Develop
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(121, 40, 202), rgb(255, 0, 128))',
backgroundClip: 'text',
color: 'transparent',
}}
>
Preview
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(255, 77, 77), rgb(249, 203, 40))',
backgroundClip: 'text',
color: 'transparent',
}}
>
Ship ❤️
</div>
<img src="https://picsum.photos/150" width={150} height={150}/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#DD2E44"
d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>
</div>);

return new ImageResponse(
targetJsx, {
width: 1200,
height: 627,
fonts: [
{
style: "normal",
name: "Noto Sans KR",
data: notoSansFontBuffer,
weight: 600,
},
{
style: "normal",
name: "Noto Color Emoji",
data: notoColorEmojiFontBuffer,
},
],
}
)
}

이게 원래는 next/server 패키지에 있다가, 14버전에 와서 next/og 패키지로 이동을 했다. 13.4에 추가가 되었으며 내부에는 @vercel/og 패키지를 포함하고 있다.

위 결과는 아래와 같다.

동적으로 OG 이미지를 생성을 아주 잘해준다.

자 이제부터 진정한 삽질 시작이다.

2. satori 직접 사용하여 처리하기

npm i -S satori sharp

satori 와 sharp 패키지를 의존성에 추가했다.

폰트는 Noto Sans KR 과 Noto Color Emoji 폰트를 다운로드 받아 추가했다.

import {NextResponse} from "next/server";
import satori from 'satori';
import sharp from "sharp";
import fs from 'fs';
import path from 'path';

export async function GET(request: Request) {

const notoSansFontBuffer = fs.readFileSync(path.join(process.cwd(), 'public', 'fonts', 'NotoSansKR-SemiBold.ttf'));
const notoColorEmojiFontBuffer = fs.readFileSync(path.join(process.cwd(), 'public', 'fonts', 'NotoColorEmoji-Regular.ttf'));

const svg = await satori((<div
style={{
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
backgroundImage: 'linear-gradient(to bottom, #dbf4ff, #fff1f1)',
fontSize: 60,
letterSpacing: -2,
fontWeight: 700,
textAlign: 'center',
}}
>
<svg
height={80}
viewBox="0 0 75 65"
fill="black"
style={{margin: '0 75px'}}
>
<path d="M37.59.25l36.95 64H.64l36.95-64z"></path>
</svg>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Develop
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(121, 40, 202), rgb(255, 0, 128))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Preview
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(255, 77, 77), rgb(249, 203, 40))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Ship ❤️
</div>
<img src="https://picsum.photos/150" width={150} height={150}/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#DD2E44"
d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>
</div>), {
width: 1200,
height: 627,
fonts: [
{
style: "normal",
name: "Noto Sans KR",
data: notoSansFontBuffer,
weight: 600,
},
{
style: "normal",
name: "Noto Color Emoji",
data: notoColorEmojiFontBuffer,
weight: 400,
},
],
},);

const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();

return new NextResponse(pngBuffer, {
status: 200,
headers: {
'Content-Type': 'image/png',
}
});

// return new NextResponse(svg, {
// status: 200,
// headers: {
// 'Content-Type': 'image/svg+xml',
// }
// });
}

자 이제 결과는?

? 이모지도 안나오고, svg 태그도 출력이 안된다. 그럼 sharp 가 png 로 변환하기전 svg 는 어떨까?

이모지만 제대로 처리가 안된게 확인이 된다.

  • 기본적으로 일반 폰트는 이모지 표현을 못한다.
  • 이모지를 표현하고자, 이모지 표현이 가능한 폰트를 추가하였다.

결과는 실패했다. twemoji 같은 폰트를 추가해줘도 그냥 공백으로 나오는게 전부였다.

3. satori 직접 사용하여 처리하기(+resvg + emoji 처리)

2번 삽질에서, 먼저 접근을 한건 이모지 처리를 어떻게 해야하는가?였다.

satori 문서를 살펴보면, 이모지 대응이 가능한 옵션이 있는데
graphemeImages, loadAdditionalAsset 를 제공해주고 있다.

처음에는 graphemeImages 를 통해 지정한 이모지 문자를 지정한 이미지 URL로 대응하도록 처리하려고 해보았다. svg 결과물에는 잘 처리가 된게 확인이 되었다. 그렇지만 역시나 sharp 를 활용하여 png 로 변환하면 없어지는 이슈가 발생하였다.

그리고 사실 모든 이모지에 대해서, 하나하나 대응시키는것은 현실적으로 어렵기 때문에 패스를 하였다.

결국에는 loadAdditionalAsset 을 통해 문자의 언어를 동적으로 읽어와서 처리를 해야했다.

await satori(
<div>👋 你好</div>,
{
// `code` will be the detected language code, `emoji` if it's an Emoji, or `unknown` if not able to tell.
// `segment` will be the content to render.
loadAdditionalAsset: async (code: string, segment: string) => {
if (code === 'emoji') {
// if segment is an emoji
return `data:image/svg+xml;base64,...`
}

// if segment is normal text
return loadFontFromSystem(code)
}
}
)

위 예시에는 실제 처리가 생략되어있어서, 이걸로 뭘 어떻게 하라는건가라는 의문이 들었다. 그런데 satori 문서 하단에 playground(https://og-playground.vercel.app/) 를 제공해주고 있는데 여기서는 엄청 잘 동작을 한다.

똑같은 satori 를 쓰는데 대체 왜 내가 작성한 코드만 말썽을 일으키지? 하면서 저장소 코드를 내려받고 하나하나 살펴보았다.

await satori(live.element.prototype.render(), {
...options,
embedFont: fontEmbed,
width,
height,
debug: true,
loadAdditionalAsset: (code: string, text: string) =>
loadDynamicAsset(emojiType, code, text),
})

이사람들도 loadAdditionalAsset 으로 이모지 대응처리한거 같다. 다시 코드를 읽어내려가면서 아래와 같은 코드를 발견했다.

 if (_code === 'emoji') {
// It's an emoji, load the image.
return (
`data:image/svg+xml;base64,` +
btoa(await loadEmoji(emojiType, getIconCode(text)))
)
}

loadEmoji 함수와 getIconCode 는 utils/twemoji.ts 파일에 내용이 작성이 되어있었다.

/**
* Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js.
*/

/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */

const U200D = String.fromCharCode(8205)
const UFE0Fg = /\uFE0F/g

export function getIconCode(char: string) {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, '') : char)
}

function toCodePoint(unicodeSurrogates: string) {
const r = []
let c = 0,
p = 0,
i = 0

while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++)
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16))
p = 0
} else if (55296 <= c && c <= 56319) {
p = c
} else {
r.push(c.toString(16))
}
}
return r.join('-')
}

export const apis = {
twemoji: (code: string) =>
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
code.toLowerCase() +
'.svg',
openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/',
blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/',
noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/',
fluent: (code: string) =>
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' +
code.toLowerCase() +
'_color.svg',
fluentFlat: (code: string) =>
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' +
code.toLowerCase() +
'_flat.svg',
}

const emojiCache: Record<string, Promise<any>> = {}

export function loadEmoji(type: keyof typeof apis, code: string) {
const key = type + ':' + code
if (key in emojiCache) return emojiCache[key]

if (!type || !apis[type]) {
type = 'twemoji'
}

const api = apis[type]
if (typeof api === 'function') {
return (emojiCache[key] = fetch(api(code)).then((r) => r.text()))
}
return (emojiCache[key] = fetch(`${api}${code.toUpperCase()}.svg`).then((r) =>
r.text()
))
}

위 코드 내용은 이모지 문자열을 받으면 그걸 유니코드로 변환을 하고, cdn 에 올려진 이모지 svg 이미지 파일명은 이 유니코드 값이기때문에 그에 대응하여 fetch 를 해와서 btoa() 를 활용하여 Base64 인코딩을 하고 Data URL 에 추가해주는 형태다.

저 로직을 가져다가 사용하면 좋겠다라고 해서, 복사해서 아래와 같이 작성을 하였다.

import {NextResponse} from "next/server";
import satori from 'satori';
import fs from 'fs';
import path from 'path';
import {convertSvgToPngByResvg, convertSvgToPngBySharp} from "@/utils/svgToPngUtil";

/**
* Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js.
*/

/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */

const U200D = String.fromCharCode(8205)
const UFE0Fg = /\uFE0F/g

function getIconCode(char: string) {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, '') : char)
}

function toCodePoint(unicodeSurrogates: string) {
const r = []
let c = 0,
p = 0,
i = 0

while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++)
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16))
p = 0
} else if (55296 <= c && c <= 56319) {
p = c
} else {
r.push(c.toString(16))
}
}
return r.join('-')
}

export const apis = {
twemoji: (code: string) =>
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
code.toLowerCase() +
'.svg',
openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/',
blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/',
noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/',
fluent: (code: string) =>
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' +
code.toLowerCase() +
'_color.svg',
fluentFlat: (code: string) =>
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' +
code.toLowerCase() +
'_flat.svg',
}

function loadEmoji(type: keyof typeof apis, code: string) {
const key = type + ':' + code

if (!type || !apis[type]) {
type = 'twemoji'
}

const api = apis[type]
if (typeof api === 'function') {
return fetch(api(code)).then((r) => r.text());
}
return fetch(`${api}${code.toUpperCase()}.svg`).then((r) =>
r.text()
)
}


export async function GET(request: Request) {

const notoSansFontBuffer = fs.readFileSync(path.join(process.cwd(), 'public', 'fonts', 'NotoSansKR-SemiBold.ttf'));

const svg = await satori((<div
style={{
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
backgroundImage: 'linear-gradient(to bottom, #dbf4ff, #fff1f1)',
fontSize: 60,
letterSpacing: -2,
fontWeight: 700,
textAlign: 'center',
}}
>
<svg
height={80}
viewBox="0 0 75 65"
fill="black"
style={{margin: '0 75px'}}
>
<path d="M37.59.25l36.95 64H.64l36.95-64z"></path>
</svg>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Develop
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(121, 40, 202), rgb(255, 0, 128))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Preview
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(255, 77, 77), rgb(249, 203, 40))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Ship ❤️
</div>
<img src="https://picsum.photos/150" width={150} height={150}/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#DD2E44"
d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>
</div>), {
width: 1200,
height: 627,
fonts: [
{
style: "normal",
name: "Noto Sans KR",
data: notoSansFontBuffer,
weight: 600,
},
],
loadAdditionalAsset: async (code: string, segment: string) => {
// console.log(code, segment, Buffer.from(segment).toString('base64'));
// console.log(getIconCode(segment));
// console.log(await loadEmoji('twemoji', getIconCode(segment)));
// return loadDynamicAsset('twemoji', code, segment);
if (code === 'emoji') {
return `data:image/svg+xml;base64,` +
btoa(await loadEmoji('twemoji', getIconCode(segment)));
}

return code;
}
},);

// const pngBuffer = convertSvgToPngBySharp(svg);

// const pngBuffer2 = convertSvgToPng(svg);

// return new NextResponse(pngBuffer, {
// status: 200,
// headers: {
// 'Content-Type': 'image/png',
// 'Content-Length': String(pngBuffer.length),
// }
// });

return new NextResponse(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
}
});
}

응답을 일단, svg 로 받도록 해놓고 확인을 해보았다.

오.. 잘 처리가 된다. 그럼 이제 이거 resvg 사용해서 변환을 해볼까?

import sharp from "sharp";
import {Resvg} from "@resvg/resvg-js";

export function convertSvgToPngByResvg(targetSvg: Buffer | string) {
const resvg = new Resvg(targetSvg, {});
const pngData = resvg.render();
return pngData.asPng();
}

export async function convertSvgToPngBySharp(targetSvg: string) {
return sharp(Buffer.from(targetSvg)).png().toBuffer();
}

위와 같은 유틸함수를 작성하고, import 하여 딱 사용하는데!!

그렇다.. 주석처리해둔 이유가 있었다.

    const pngBuffer = convertSvgToPngByResvg(svg);

return new NextResponse(pngBuffer, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Content-Length': String(pngBuffer.length),
}
});
 ⨯ ./node_modules/@resvg/resvg-js-win32-x64-msvc/resvgjs.win32-x64-msvc.node
Module parse failed: Unexpected character '�' (1:2)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
(Source code omitted for this binary file)

Import trace for requested module:
./node_modules/@resvg/resvg-js-win32-x64-msvc/resvgjs.win32-x64-msvc.node
./node_modules/@resvg/resvg-js/js-binding.js
./node_modules/@resvg/resvg-js/index.js
./src/utils/svgToPngUtil.ts
./src/app/api/satori/route.tsx
○ Compiling /not-found ...
⨯ ./node_modules/@resvg/resvg-js-win32-x64-msvc/resvgjs.win32-x64-msvc.node
Module parse failed: Unexpected character '�' (1:2)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
(Source code omitted for this binary file)

Import trace for requested module:
./node_modules/@resvg/resvg-js-win32-x64-msvc/resvgjs.win32-x64-msvc.node
./node_modules/@resvg/resvg-js/js-binding.js
./node_modules/@resvg/resvg-js/index.js
./src/utils/svgToPngUtil.ts
./src/app/api/satori/route.tsx

오 에러 :)…….

정리

  • vercel/og 패키지를 활용하면, 동적으로 이미지 생성이 가능
  • next/og 패키지는 내부에 vercel/og 패키지를 포함하고 있음
  • vercel/og 패키지는 satori 를 사용하여 SVG 를 출력하고 resvg 를 통해 이미지(png)로 변환
  • satori 는 JSX 를 지원하므로 React 개발자들은 평소 작성하던대로 마크업 작성이 가능하다.
  • satori 는 내부적으로 opentype.js 라는 패키지를 사용하여 글꼴을 처리한다.(TTF, OTF, WOFF 를 지원)
  • svg 로는 잘 출력이 되었으나, sharp 를 사용하여 png 변환 시 누락되는 경우가 발생
  • 이모지 이슈가 없다면, 아주 편안하게 사용이 가능
  • satori 에서 제공해주는 playground 는 아주 정상적으로 동작 -> 결론은 내가 못하는걸로..

정리 하다가, 빡쳐서 코드 재분석해서 하니 처리를 해버렸다..

import {NextResponse} from "next/server";
import satori from 'satori';
import fs from 'fs';
import path from 'path';
import {convertSvgToPngByResvg, convertSvgToPngBySharp} from "@/utils/svgToPngUtil";

/**
* Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js.
*/

/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */

const U200D = String.fromCharCode(8205)
const UFE0Fg = /\uFE0F/g

function getIconCode(char: string) {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, '') : char)
}

function toCodePoint(unicodeSurrogates: string) {
const r = []
let c = 0,
p = 0,
i = 0

while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++)
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16))
p = 0
} else if (55296 <= c && c <= 56319) {
p = c
} else {
r.push(c.toString(16))
}
}
return r.join('-')
}

export const apis = {
twemoji: (code: string) =>
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
code.toLowerCase() +
'.svg',
openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/',
blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/',
noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/',
fluent: (code: string) =>
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' +
code.toLowerCase() +
'_color.svg',
fluentFlat: (code: string) =>
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' +
code.toLowerCase() +
'_flat.svg',
}

function loadEmoji(type: keyof typeof apis, code: string) {
const key = type + ':' + code

if (!type || !apis[type]) {
type = 'twemoji'
}

const api = apis[type]
if (typeof api === 'function') {
return fetch(api(code)).then((r) => r.text());
}
return fetch(`${api}${code.toUpperCase()}.svg`).then((r) =>
r.text()
)
}


export async function GET(request: Request) {

const notoSansFontBuffer = fs.readFileSync(path.join(process.cwd(), 'public', 'fonts', 'NotoSansKR-SemiBold.ttf'));

const svg = await satori((<div
style={{
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
backgroundImage: 'linear-gradient(to bottom, #dbf4ff, #fff1f1)',
fontSize: 60,
letterSpacing: -2,
fontWeight: 700,
textAlign: 'center',
}}
>
<svg
height={80}
viewBox="0 0 75 65"
fill="black"
style={{margin: '0 75px'}}
>
<path d="M37.59.25l36.95 64H.64l36.95-64z"></path>
</svg>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Develop
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(121, 40, 202), rgb(255, 0, 128))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Preview
</div>
<div
style={{
backgroundImage: 'linear-gradient(90deg, rgb(255, 77, 77), rgb(249, 203, 40))',
backgroundClip: 'text',
'-webkit-background-clip': 'text',
color: 'transparent',
}}
>
Ship ❤️
</div>
<img src="https://picsum.photos/150" width={150} height={150}/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#DD2E44"
d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>
</div>), {
width: 1200,
height: 627,
fonts: [
{
style: "normal",
name: "Noto Sans KR",
data: notoSansFontBuffer,
weight: 600,
},
],
loadAdditionalAsset: async (code: string, segment: string) => {
// console.log(code, segment, Buffer.from(segment).toString('base64'));
// console.log(getIconCode(segment));
// console.log(await loadEmoji('twemoji', getIconCode(segment)));
// return loadDynamicAsset('twemoji', code, segment);
if (code === 'emoji') {
return `data:image/svg+xml;base64,` +
btoa(await loadEmoji('twemoji', getIconCode(segment)));
}

return code;
}
},);

const pngBuffer = await convertSvgToPngByResvg(svg);

return new NextResponse(Buffer.from(pngBuffer!), {
status: 200,
headers: {
'Content-Type': 'image/png',
}
});

// return new NextResponse(svg, {
// status: 200,
// headers: {
// 'Content-Type': 'image/svg+xml',
// }
// });
}
import sharp from "sharp";
// import {Resvg} from "@resvg/resvg-js";
import { initWasm, Resvg } from '@resvg/resvg-wasm';
import path from "path";
import fs from "fs";

export async function convertSvgToPngByResvg(targetSvg: Buffer | string) {
try {
// const resvg = new Resvg(targetSvg, {});
// const pngData = resvg.render();
// return pngData.asPng();
const wasmPath = path.resolve( './node_modules/@resvg/resvg-wasm/index_bg.wasm');
const wasmBuffer = fs.readFileSync(wasmPath);
await initWasm(wasmBuffer);
const renderer = new Resvg(targetSvg, {});
const image = renderer.render();
return image.asPng();
} catch (e) {
console.error(e);
}
}

export async function convertSvgToPngBySharp(targetSvg: string) {
return sharp(Buffer.from(targetSvg)).png().toBuffer();
}

플레이그라운드 프로젝트만 딱 분리해서, 실행을 해보았을 때 정상적으로 처리되도록 한 옵션들이 뭐가 있나 살펴보았다. resvg-wasm 모듈로 png 처리를 하는게 눈에 뛰어서, 이를 불러오는 코드를 추가하려 처리하고 버퍼로 변환하여 반환하니 성공했다..!

satori 는 진짜 svg 만 툭 던져주는데.. 이후에 업데이트 하면 이모지까지는 자동으로 처리가 되게끔 해주면 좋지 않을까 생각이 든다.

흑흑 응답결과를 보아도, 이미지인게 보인다 ㅠㅠ!

--

--