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