main.go

  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}