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