redirecter for ao3 that adds opengraph metadata
1import { NextResponse } from "next/server"
2import { createCanvas, createImageData, loadImage, registerFont, CanvasRenderingContext2D } from "canvas"
3import { Path2D, applyPath2DToCanvasRenderingContext } from "path2d"
4import { drawText } from "canvas-txt"
5import baseFonts from "./baseFonts.js"
6import titleFonts from "./titleFonts.js"
7import { catYuriIcon, catYaoiIcon, catHetIcon, catGenIcon, catMultiIcon, catOtherIcon } from "./categoryIcons.js"
8import { ratingNoRatingIcon, ratingExplicitIcon, ratingMatureIcon, ratingTeenIcon, ratingGeneralIcon } from "./ratingIcons.js"
9import { warningChoseNotToWarnIcon, warningWarningsIcon, warningNoWarningsIcon } from "./warningIcons.js"
10import { checkItem, checkToggle } from '@/lib/propUtils.js'
11
12export default async function OGImage ({ theme, baseFont, titleFont, image, addr, opts }) {
13 applyPath2DToCanvasRenderingContext(CanvasRenderingContext2D)
14 baseFonts[baseFont].defs.forEach((font) => {
15 registerFont(process.cwd()+font.path, {family: baseFonts[baseFont].displayName, style: font.style, weight: font.weight})
16 })
17 titleFonts[titleFont].defs.forEach((font) => {
18 registerFont(process.cwd()+font.path, {family: titleFonts[titleFont].displayName, style: font.style, weight: font.weight})
19 })
20 const canvas = createCanvas(1600, 900)
21 const ctx = canvas.getContext('2d')
22 ctx.fillStyle = theme.background
23 ctx.fillRect(0, 0, 1600, 900)
24 let yCursor = 40;
25 ctx.fillStyle = theme.color
26 const iconCount = (checkToggle('category', image.props) ? 1 : 0) + (checkToggle('rating', image.props) ? 1 : 0) + (checkToggle('warnings', image.props) ? 1 : 0)
27 if (iconCount > 0) {
28 let iconCursor = 800 - (36*iconCount + 12*(iconCount - 1))/2
29 if (checkToggle('category', image.props)) {
30 let catIcon;
31 ctx.fillStyle = theme.accent
32 ctx.beginPath()
33 ctx.ellipse(iconCursor, yCursor, 18, 18, 0, 0, 2*Math.PI);
34 ctx.fill()
35 ctx.closePath()
36 ctx.beginPath()
37 ctx.fillStyle = theme.background
38 switch (image.category) {
39 case 'F':
40 catIcon = catYuriIcon
41 break
42 case 'M':
43 catIcon = catYaoiIcon
44 break
45 case 'FM':
46 catIcon = catHetIcon
47 break
48 case 'G':
49 catIcon = catGenIcon
50 break
51 case 'MX':
52 catIcon = catMultiIcon
53 break
54 case 'O':
55 default:
56 catIcon = catOtherIcon
57 break
58 }
59 ctx.translate(iconCursor - 18, yCursor - 18)
60 const catPath = new Path2D(catIcon)
61 ctx.fill(catPath)
62 ctx.closePath()
63 ctx.setTransform(1, 0, 0, 1, 0, 0);
64 iconCursor += 48
65 }
66 if (checkToggle('rating', image.props)) {
67 let ratingIcon;
68 ctx.beginPath()
69 ctx.fillStyle = theme.accent2
70 ctx.ellipse(iconCursor, yCursor, 18, 18, 0, 0, 360);
71 ctx.fill()
72 ctx.closePath()
73 ctx.beginPath()
74 ctx.fillStyle = theme.background
75 switch (image.rating) {
76 case 'Not Rated':
77 ratingIcon = ratingNoRatingIcon
78 break
79 case 'Explicit':
80 ratingIcon = ratingExplicitIcon
81 break
82 case 'Mature':
83 ratingIcon = ratingMatureIcon
84 break
85 case 'Teen':
86 ratingIcon = ratingTeenIcon
87 break
88 case 'General':
89 default:
90 ratingIcon = ratingGeneralIcon
91 break
92 }
93 ctx.translate(iconCursor - 18, yCursor - 18)
94 const ratingPath = new Path2D(ratingIcon)
95 ctx.fill(ratingPath)
96 ctx.closePath()
97 ctx.setTransform(1, 0, 0, 1, 0, 0);
98 iconCursor += 48
99 }
100 if (checkToggle('warnings', image.props)) {
101 let warningIcon;
102 ctx.beginPath()
103 ctx.fillStyle = theme.accent3
104 ctx.ellipse(iconCursor, yCursor, 18, 18, 0, 0, 360);
105 ctx.fill()
106 ctx.closePath()
107 ctx.beginPath()
108 ctx.fillStyle = theme.background
109 switch (image.rating) {
110 case 'CNTW':
111 warningIcon = warningChoseNotToWarnIcon
112 break
113 case 'NW':
114 warningIcon = warningWarningIcon
115 break
116 case 'W':
117 default:
118 warningIcon = warningNoWarningsIcon
119 }
120 ctx.translate(iconCursor - 18, yCursor - 18)
121 const warningPath = new Path2D(warningIcon)
122 ctx.fill(warningPath)
123 ctx.closePath()
124 ctx.setTransform(1, 0, 0, 1, 0, 0);
125 }
126 yCursor += 36
127 }
128 ctx.fillStyle = theme.color
129 const topline = drawText(ctx, image.topLine, {
130 x: 40,
131 y: yCursor,
132 width: 1520,
133 height: 24,
134 fontSize: 24,
135 vAlign: 'top',
136 fill: theme.color,
137 font: baseFonts[baseFont].displayName
138 })
139 yCursor += topline.height + 24
140 const title = drawText(ctx, image.titleLine, {
141 x: 40,
142 y: yCursor,
143 width: 1520,
144 height: 54,
145 fontSize: 54,
146 vAlign: 'top',
147 fill: theme.color,
148 font: titleFonts[titleFont].displayName
149 })
150 yCursor += title.height + 24
151 const author = drawText(ctx, `by ${image.authorLine}`, {
152 x: 40,
153 y: yCursor,
154 width: 1520,
155 height: 42,
156 fontSize: 42,
157 vAlign: 'top',
158 font: titleFonts[titleFont].displayName
159 })
160 yCursor += author.height + 24
161 if (image.chapterLine) {
162 const chapter = drawText(ctx, image.chapterLine, {
163 x: 40,
164 y: yCursor,
165 width: 1520,
166 height: 36,
167 fontSize: 36,
168 vAlign: 'top',
169 font: titleFonts[titleFont].displayName
170 })
171 yCursor += chapter.height + 24
172 }
173 ctx.fillStyle = theme.descBackground
174 const descRect = ctx.fillRect(40, yCursor, 1520, (860 - yCursor))
175 ctx.fillStyle = theme.descColor
176 yCursor += 40
177 if (checkToggle('charTags', image.props)) {
178 let cxCursor = 80
179 let cyCursor = yCursor
180 ctx.font = "18px "+baseFonts[baseFont].displayName
181 image.charTags.forEach((c) => {
182 const metrics = ctx.measureText(c)
183 if (metrics.width + cxCursor > 1520) {
184 cxCursor = 80
185 cyCursor += 36
186 }
187 ctx.fillStyle = theme.accent2
188 ctx.beginPath()
189 ctx.roundRect(cxCursor, cyCursor, metrics.width+16, 32, 5);
190 ctx.fill()
191 ctx.closePath()
192 ctx.fillStyle = theme.accent2Color
193 drawText(ctx, c, {
194 x: cxCursor + 8,
195 y: cyCursor + 4,
196 fontSize: 18,
197 align: 'left',
198 vAlign: 'top',
199 font: baseFonts[baseFont].displayName
200 })
201 cxCursor = cxCursor + metrics.width + 16 + 8
202 })
203 yCursor = cyCursor + 36
204 }
205 if (checkToggle('relTags', image.props)) {
206 let rxCursor = 80
207 let ryCursor = yCursor
208 ctx.font = "18px "+baseFonts[baseFont].displayName
209 image.relTags.forEach((r) => {
210 const metrics = ctx.measureText(r)
211 if (metrics.width + rxCursor > 1520) {
212 cxCursor = 80
213 cyCursor += 36
214 }
215 ctx.fillStyle = theme.accent3
216 ctx.beginPath()
217 ctx.roundRect(rxCursor, ryCursor, metrics.width+16, 32, 5);
218 ctx.fill()
219 ctx.fillStyle = theme.accent3Color
220 drawText(ctx, r, {
221 x: rxCursor + 8,
222 y: ryCursor + 4,
223 fontSize: 18,
224 align: 'left',
225 vAlign: 'top',
226 font: baseFonts[baseFont].displayName
227 })
228 rxCursor = rxCursor + metrics.width + 16 + 8
229 })
230 yCursor = ryCursor + 36
231 }
232 if (checkToggle('freeTags', image.props)) {
233 let fxCursor = 80
234 let fyCursor = yCursor
235 ctx.font = "18px "+baseFonts[baseFont].displayName
236 image.freeTags.forEach((f) => {
237 const metrics = ctx.measureText(f)
238 if (metrics.width + fxCursor > 1520) {
239 fxCursor = 80
240 fyCursor += 36
241 }
242 ctx.fillStyle = theme.accent4
243 ctx.beginPath()
244 ctx.roundRect(fxCursor, fyCursor, metrics.width+16, 32, 5);
245 ctx.fill()
246 ctx.fillStyle = theme.accent4Color
247 drawText(ctx, f, {
248 x: fxCursor + 8,
249 y: fyCursor + 4,
250 fontSize: 18,
251 align: 'left',
252 vAlign: 'top',
253 font: baseFonts[baseFont].displayName
254 })
255 fxCursor = fxCursor + metrics.width + 16 + 8
256 })
257 yCursor = fyCursor + 36
258 }
259 yCursor += 12
260 ctx.fillStyle = theme.descColor
261 if (checkToggle('summary', image.props)) {
262 image.summary.forEach((l) => {
263 const line = drawText(ctx, l, {
264 x: 80,
265 y: yCursor,
266 width: 1440,
267 height: 1,
268 lineHeight: 30,
269 fontSize: 24,
270 align: 'left',
271 vAlign: 'top',
272 font: baseFonts[baseFont].displayName
273 })
274 yCursor += line.height + 12
275 })
276 }
277 const bottomText = `${checkToggle('wordcount', image.props) ? `${image.words} words • ` : ''}${(checkToggle('chapters', image.props) && image.chapterCount !== null) ? `${image.chapterCount} chapters • ` : ''}${checkToggle('postedAt', image.props) ? `posted on ${image.postedAt} • ` : ''}${checkToggle('updatedAt', image.props) ? `updated on ${image.updatedAt} • ` : ''}${addr}`;
278 ctx.fillStyle = theme.accent2
279 const bottomLine = drawText(ctx, bottomText, {
280 x: 80,
281 y: 820,
282 width: 1440,
283 height: 18,
284 lineHeight: 18,
285 fontSize: 18,
286 align: 'right',
287 vAlign: 'bottom',
288 font: baseFonts[baseFont].displayName
289 })
290 const headers = new Headers();
291 headers.set('Content-Type', 'image/png');
292 return new Response(canvas.toBuffer(), {
293 headers: {
294 'Content-Type': 'image/png'
295 }
296 })
297}