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