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