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