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 fmt.Println(err)
181 os.Exit(1)
182 }
183 var buf bytes.Buffer
184 if err := png.Encode(&buf, img); err != nil {
185 fmt.Println("Error: Could not encode image to PNG")
186 fmt.Println(err)
187 os.Exit(1)
188 }
189 ioutil.WriteFile(*flagOutput, buf.Bytes(), 0o644)
190
191 os.Exit(0)
192}
193
194func fontCollection() []text.FontFace {
195 regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
196 if err != nil {
197 fmt.Println("Error: Could not read regular font")
198 fmt.Println(err)
199 os.Exit(1)
200 }
201 regularFace, err := opentype.Parse(regularFaceBytes)
202 if err != nil {
203 fmt.Println("Error: Could not parse regular font")
204 fmt.Println(err)
205 os.Exit(1)
206 }
207
208 boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
209 if err != nil {
210 fmt.Println("Error: Could not read bold font")
211 fmt.Println(err)
212 os.Exit(1)
213 }
214 boldFace, err := opentype.Parse(boldFaceBytes)
215 if err != nil {
216 fmt.Println("Error: Could not parse bold font")
217 fmt.Println(err)
218 os.Exit(1)
219 }
220
221 italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
222 if err != nil {
223 fmt.Println("Error: Could not read italic font")
224 fmt.Println(err)
225 os.Exit(1)
226 }
227 italicFace, err := opentype.Parse(italicFaceBytes)
228 if err != nil {
229 fmt.Println("Error: Could not parse italic font")
230 fmt.Println(err)
231 os.Exit(1)
232 }
233
234 boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
235 if err != nil {
236 fmt.Println("Error: Could not read bold italic font")
237 fmt.Println(err)
238 os.Exit(1)
239 }
240 boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
241 if err != nil {
242 fmt.Println("Error: Could not parse bold italic font")
243 fmt.Println(err)
244 os.Exit(1)
245 }
246
247 return []text.FontFace{
248 {
249 Font: text.Font{
250 Typeface: "Primary font",
251 Variant: "",
252 Style: text.Regular,
253 Weight: text.Normal,
254 },
255 Face: regularFace,
256 },
257 {
258 Font: text.Font{
259 Typeface: "Primary font",
260 Variant: "",
261 Style: text.Regular,
262 Weight: text.Bold,
263 },
264 Face: boldFace,
265 },
266 {
267 Font: text.Font{
268 Typeface: "Primary font",
269 Variant: "",
270 Style: text.Italic,
271 Weight: text.Normal,
272 },
273 Face: italicFace,
274 },
275 {
276 Font: text.Font{
277 Typeface: "Primary font",
278 Variant: "",
279 Style: text.Italic,
280 Weight: text.Bold,
281 },
282 Face: boldItalicFace,
283 },
284 }
285}
286
287// Print help message
288func help() {
289 fmt.Println("\nUsage: p2c [options]")
290 fmt.Println("\nOptions:")
291 flag.PrintDefaults()
292 fmt.Print(`
293example: p2c -i input.md -o output.png
294
295p2c is meant for use with Hugo.
296
297It looks at...
298- The Markdown file's frontmatter fields for
299 - title
300 - date
301- The site's config.{toml/yaml/yml} fields for
302 - title (site title)
303- The git history to determine the date the post was last edited.
304
305`)
306}
307
308// Validate flags
309func validateFlags() {
310 if *flagInput == "" {
311 fmt.Println("Error: No input file specified")
312 os.Exit(1)
313 }
314 if *flagOutput == "" {
315 fmt.Println("Error: No output file specified")
316 os.Exit(1)
317 }
318}
319
320// Get the post's title, subtitle, and date
321func getPostInfo(input string) (string, string, string) {
322 if _, err := os.Stat(input); os.IsNotExist(err) {
323 fmt.Println("Error: Input file does not exist")
324 fmt.Println(err)
325 os.Exit(1)
326 }
327
328 fileContents, err := os.ReadFile(input)
329 if err != nil {
330 fmt.Println("Error: Could not read input file")
331 fmt.Println(err)
332 os.Exit(1)
333 }
334
335 var fm struct {
336 Title string `yaml:"title"`
337 Date string `yaml:"date"`
338 }
339
340 content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
341 if err != nil {
342 fmt.Println("Error: Could not parse frontmatter")
343 fmt.Println(err)
344 os.Exit(1)
345 }
346
347 fm.Date = strings.Split(fm.Date, "T")[0]
348
349 return fm.Title, fm.Date, string(content)
350}
351
352// Get the read time of the post
353func getReadTime(content string) int {
354 wordCount := len(strings.Fields(content))
355 return wordCount / 200
356}
357
358// Get the site's title
359func getSiteTitle() string {
360 validConf := ""
361 confs := []string{"config.toml", "config.yaml", "config.yml"}
362 for _, conf := range confs {
363 if _, err := os.Stat(conf); os.IsNotExist(err) {
364 continue
365 }
366 validConf = conf
367 }
368 if validConf == "" {
369 fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
370 os.Exit(1)
371 }
372
373 var t struct {
374 Title string `yaml:"title"`
375 }
376
377 f, err := os.Open(validConf)
378 if err != nil {
379 fmt.Println("Error: Could not open config file")
380 fmt.Println(err)
381 os.Exit(1)
382 }
383 defer f.Close()
384
385 decoder := yaml.NewDecoder(f)
386 err = decoder.Decode(&t)
387 if err != nil {
388 fmt.Println("Error: Could not parse config file")
389 fmt.Println(err)
390 os.Exit(1)
391 }
392
393 return t.Title
394}
395
396// Get the date the post was last edited
397func getGitDate(input string) string {
398 if _, err := os.Stat(input); os.IsNotExist(err) {
399 fmt.Println("Error: Input file does not exist")
400 os.Exit(1)
401 }
402
403 repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
404 if err != nil {
405 fmt.Println("Error: Could not open git repository")
406 fmt.Println(err)
407 os.Exit(1)
408 }
409
410 commitIter, err := repo.Log(&git.LogOptions{FileName: &input})
411 if err != nil {
412 fmt.Println("Error: Could not get git history")
413 fmt.Println(err)
414 os.Exit(1)
415 }
416
417 commit, err := commitIter.Next()
418 if err != nil {
419 fmt.Println("Error: Could not get git history")
420 fmt.Println(err)
421 os.Exit(1)
422 }
423
424 return commit.Committer.When.Format("2006-01-02")
425}