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