1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: BSD-3-Clause
4
5package main
6
7import (
8 "bytes"
9 "embed"
10 "fmt"
11 "image"
12 "image/color"
13 "image/png"
14 "os"
15 "strings"
16
17 "gioui.org/font/opentype"
18 "gioui.org/gpu/headless"
19 "gioui.org/io/router"
20 "gioui.org/layout"
21 "gioui.org/op"
22 "gioui.org/op/clip"
23 "gioui.org/op/paint"
24 "gioui.org/text"
25 "gioui.org/unit"
26 "gioui.org/widget/material"
27 "github.com/adrg/frontmatter"
28 flag "github.com/spf13/pflag"
29 "gopkg.in/yaml.v3"
30)
31
32var (
33 flagHelp *bool = flag.BoolP("help", "h", false, "Show the help message")
34 flagInput *string = flag.StringP("input", "i", "", "Path to input Markdown")
35 flagOutput *string = flag.StringP("output", "o", "", "Path to output PNG")
36 flagMetaSize *int = flag.IntP("metasize", "M", 40, "Size of font for meta information")
37 flagPostTitleSize *int = flag.IntP("posttitlesize", "P", 60, "Size of font for post title")
38 flagSiteTitleSize *int = flag.IntP("sitetitlesize", "S", 50, "Size of font for site title")
39 flagTitle *string = flag.StringP("posttitle", "t", "", "Title displayed in the generated image")
40 flagSubtitle *string = flag.StringP("subtitle", "s", "", "Subtitle displayed in the generated image")
41)
42
43//go:embed fonts
44var fontEmbed embed.FS
45
46func main() {
47 flag.Parse()
48
49 if *flagHelp {
50 help()
51 os.Exit(0)
52 }
53
54 manual := validateFlags()
55
56 var postTitle, postDate, postContent, postReadTime, dateEdited string
57
58 if !manual {
59 postTitle, postDate, postContent = getPostInfo(*flagInput)
60 postReadTime = getReadTime(postContent)
61 dateEdited = getEditDate(*flagInput)
62 }
63
64 siteTitle := getSiteTitle()
65
66 collection := fontCollection()
67
68 const scale = 1
69 sz := image.Point{X: 1200 * scale, Y: 630 * scale}
70
71 w, err := headless.NewWindow(sz.X, sz.Y)
72 if err != nil {
73 panic(err)
74 }
75 th := material.NewTheme(collection)
76 gtx := layout.Context{
77 Ops: new(op.Ops),
78 Queue: new(router.Router),
79 Constraints: layout.Exact(sz),
80 Metric: unit.Metric{
81 PxPerDp: scale,
82 PxPerSp: scale,
83 },
84 }
85 paint.Fill(gtx.Ops, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
86 layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
87 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .6)
88 gtx.Constraints.Min.X = gtx.Constraints.Max.X
89 gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
90
91 return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle, Spacing: layout.SpaceAround}.Layout(gtx,
92
93 layout.Rigid(layout.Spacer{Height: 50}.Layout),
94
95 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
96 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
97 gtx.Constraints.Min.X = gtx.Constraints.Max.X
98 var title material.LabelStyle
99 if manual {
100 title = material.Label(th, unit.Sp(float32(*flagPostTitleSize)), *flagTitle)
101 } else {
102 title = material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
103 }
104 title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
105 title.Alignment = text.Middle
106 return title.Layout(gtx)
107 }),
108
109 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
110 if manual {
111 subtitle := material.Label(th, unit.Sp(float32(*flagMetaSize)), *flagSubtitle)
112 subtitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Italic, Weight: text.Normal}
113 subtitle.Alignment = text.Middle
114 return subtitle.Layout(gtx)
115 } else {
116 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .7)
117 gtx.Constraints.Min.X = gtx.Constraints.Max.X
118 return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
119 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
120 return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
121 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
122 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
123 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
124 rTime.Alignment = text.Start
125 return rTime.Layout(gtx)
126 }),
127 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
128 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), postReadTime)
129 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
130 rTime.Alignment = text.End
131 return rTime.Layout(gtx)
132 }),
133 )
134 }),
135
136 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
137 return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
138 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
139 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
140 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
141 pDate.Alignment = text.Start
142 return pDate.Layout(gtx)
143 }),
144 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
145 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
146 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
147 pDate.Alignment = text.End
148 return pDate.Layout(gtx)
149 }),
150 )
151 }),
152
153 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
154 return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
155 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
156 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
157 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
158 eDate.Alignment = text.Start
159 return eDate.Layout(gtx)
160 }),
161 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
162 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
163 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
164 eDate.Alignment = text.End
165 return eDate.Layout(gtx)
166 }),
167 )
168 }),
169 )
170 }
171 }),
172
173 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
174 size := image.Pt(gtx.Constraints.Max.X, 4)
175 defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
176 paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(gtx.Ops)
177 paint.PaintOp{}.Add(gtx.Ops)
178 return layout.Dimensions{Size: size}
179 }),
180
181 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
182 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
183 gtx.Constraints.Min.X = gtx.Constraints.Max.X
184 sTitle := material.Label(th, unit.Sp(float32(*flagSiteTitleSize)), siteTitle)
185 sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
186 sTitle.Alignment = text.Middle
187 return sTitle.Layout(gtx)
188 }),
189 layout.Rigid(layout.Spacer{Height: 50}.Layout),
190 )
191 })
192 err = w.Frame(gtx.Ops)
193 if err != nil {
194 fmt.Println("Error: Could not render GUI")
195 fmt.Println(err)
196 os.Exit(1)
197 }
198 img := image.NewRGBA(image.Rectangle{Max: sz})
199 err = w.Screenshot(img)
200 if err != nil {
201 fmt.Println("Error: Could not transfer GUI to image")
202 fmt.Println(err)
203 os.Exit(1)
204 }
205 var buf bytes.Buffer
206 if err := png.Encode(&buf, img); err != nil {
207 fmt.Println("Error: Could not encode image to PNG")
208 fmt.Println(err)
209 os.Exit(1)
210 }
211 // Write the image to a file
212 err = os.WriteFile(*flagOutput, buf.Bytes(), 0o644)
213 if err != nil {
214 fmt.Println("Error: Could not write image to file")
215 fmt.Println(err)
216 os.Exit(1)
217 }
218
219 os.Exit(0)
220}
221
222func fontCollection() []text.FontFace {
223 regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
224 if err != nil {
225 fmt.Println("Error: Could not read regular font")
226 fmt.Println(err)
227 os.Exit(1)
228 }
229 regularFace, err := opentype.Parse(regularFaceBytes)
230 if err != nil {
231 fmt.Println("Error: Could not parse regular font")
232 fmt.Println(err)
233 os.Exit(1)
234 }
235
236 boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
237 if err != nil {
238 fmt.Println("Error: Could not read bold font")
239 fmt.Println(err)
240 os.Exit(1)
241 }
242 boldFace, err := opentype.Parse(boldFaceBytes)
243 if err != nil {
244 fmt.Println("Error: Could not parse bold font")
245 fmt.Println(err)
246 os.Exit(1)
247 }
248
249 italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
250 if err != nil {
251 fmt.Println("Error: Could not read italic font")
252 fmt.Println(err)
253 os.Exit(1)
254 }
255 italicFace, err := opentype.Parse(italicFaceBytes)
256 if err != nil {
257 fmt.Println("Error: Could not parse italic font")
258 fmt.Println(err)
259 os.Exit(1)
260 }
261
262 boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
263 if err != nil {
264 fmt.Println("Error: Could not read bold italic font")
265 fmt.Println(err)
266 os.Exit(1)
267 }
268 boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
269 if err != nil {
270 fmt.Println("Error: Could not parse bold italic font")
271 fmt.Println(err)
272 os.Exit(1)
273 }
274
275 return []text.FontFace{
276 {
277 Font: text.Font{
278 Typeface: "Primary font",
279 Variant: "",
280 Style: text.Regular,
281 Weight: text.Normal,
282 },
283 Face: regularFace,
284 },
285 {
286 Font: text.Font{
287 Typeface: "Primary font",
288 Variant: "",
289 Style: text.Regular,
290 Weight: text.Bold,
291 },
292 Face: boldFace,
293 },
294 {
295 Font: text.Font{
296 Typeface: "Primary font",
297 Variant: "",
298 Style: text.Italic,
299 Weight: text.Normal,
300 },
301 Face: italicFace,
302 },
303 {
304 Font: text.Font{
305 Typeface: "Primary font",
306 Variant: "",
307 Style: text.Italic,
308 Weight: text.Bold,
309 },
310 Face: boldItalicFace,
311 },
312 }
313}
314
315// Print help message
316func help() {
317 fmt.Println("\nUsage: p2c [options]")
318 fmt.Println("\nOptions:")
319 flag.PrintDefaults()
320 fmt.Print(`
321example: p2c -i input.md -o output.png
322
323p2c is meant for use with Hugo.
324
325It looks at...
326- The Markdown file's frontmatter fields for
327 - title
328 - date
329- The site's config.{toml/yaml/yml} fields for
330 - title (site title)
331- The file's metadata for the last modified date
332
333`)
334}
335
336// Validate flags and return true if the user is manually specifying the title
337// and subtitle
338func validateFlags() bool {
339 ret := false
340 if *flagInput == "" && (*flagTitle == "" || *flagSubtitle == "") {
341 fmt.Println("Error: input file not specified and title or subtitle not specified. Please specify EITHER an input file or both a title AND subtitle.")
342 os.Exit(1)
343 } else if *flagInput != "" && (*flagTitle != "" || *flagSubtitle != "") {
344 fmt.Println("Warning: input file specified and title or subtitle specified. Ignoring title and subtitle flags and continuing with input file")
345 } else if *flagTitle != "" && *flagSubtitle != "" {
346 ret = true
347 } else if *flagTitle == "" && *flagSubtitle != "" {
348 fmt.Println("Error: subtitle specified but title not specified")
349 os.Exit(1)
350 } else if *flagTitle != "" && *flagSubtitle == "" {
351 fmt.Println("Error: title specified but subtitle not specified")
352 os.Exit(1)
353 }
354
355 if *flagOutput == "" {
356 fmt.Println("Error: No output file specified")
357 os.Exit(1)
358 }
359
360 return ret
361}
362
363// Get the post's title, subtitle, and date
364func getPostInfo(input string) (string, string, string) {
365 if _, err := os.Stat(input); os.IsNotExist(err) {
366 fmt.Println("Error: Input file does not exist")
367 fmt.Println(err)
368 os.Exit(1)
369 }
370
371 fileContents, err := os.ReadFile(input)
372 if err != nil {
373 fmt.Println("Error: Could not read input file")
374 fmt.Println(err)
375 os.Exit(1)
376 }
377
378 var fm struct {
379 Title string `yaml:"title"`
380 Date string `yaml:"date"`
381 }
382
383 content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
384 if err != nil {
385 fmt.Println("Error: Could not parse frontmatter")
386 fmt.Println(err)
387 os.Exit(1)
388 }
389
390 fm.Date = strings.Split(fm.Date, "T")[0]
391
392 return fm.Title, fm.Date, string(content)
393}
394
395// Get the read time of the post
396func getReadTime(content string) string {
397 wordCount := len(strings.Fields(content))
398 if wordCount/200 == 0 || wordCount/200 == 1 {
399 return "1 minute"
400 }
401 return fmt.Sprintf("%d minutes", wordCount/200)
402}
403
404// Get the site's title
405func getSiteTitle() string {
406 validConf := ""
407 confs := []string{"config.toml", "config.yaml", "config.yml"}
408 for _, conf := range confs {
409 if _, err := os.Stat(conf); os.IsNotExist(err) {
410 continue
411 }
412 validConf = conf
413 }
414 if validConf == "" {
415 fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
416 os.Exit(1)
417 }
418
419 var t struct {
420 Title string `yaml:"title"`
421 }
422
423 f, err := os.Open(validConf)
424 if err != nil {
425 fmt.Println("Error: Could not open config file")
426 fmt.Println(err)
427 os.Exit(1)
428 }
429 defer f.Close()
430
431 decoder := yaml.NewDecoder(f)
432 err = decoder.Decode(&t)
433 if err != nil {
434 fmt.Println("Error: Could not parse config file")
435 fmt.Println(err)
436 os.Exit(1)
437 }
438
439 return t.Title
440}
441
442// Get the date the post was last edited using the file metadata
443func getEditDate(input string) string {
444 fileInfo, err := os.Stat(input)
445 if err != nil {
446 fmt.Println("Error: Could not get file info")
447 fmt.Println(err)
448 os.Exit(1)
449 }
450
451 return fileInfo.ModTime().Format("2006-01-02")
452}